@wtfalch/files 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -4
- package/dist/client.d.ts +72 -2
- package/dist/client.js +89 -1
- package/dist/types.d.ts +148 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,8 +38,8 @@ a day. `uploadPartUrls(uploadId, from, count)` returns presigned URLs for up to
|
|
|
38
38
|
100 parts. PUT part n as bytes `[(n-1)*partBytes, n*partBytes)` and keep each
|
|
39
39
|
response's `ETag` header. Then call `confirmUpload(uploadId, parts)` with every
|
|
40
40
|
part in order. To resume, ask for fresh URLs for the parts that failed. A
|
|
41
|
-
multipart file has `checksum: null`.
|
|
42
|
-
|
|
41
|
+
multipart file has `checksum: null`. Both the R2 and local-disk backends
|
|
42
|
+
support this.
|
|
43
43
|
|
|
44
44
|
`getFile(id, version)` and `downloadFile(id, { version })` pin the read to an
|
|
45
45
|
exact file version. A mismatch against the file's current version throws
|
|
@@ -51,12 +51,44 @@ older version to fall back to. Every `StoredFile` also carries `sha256`
|
|
|
51
51
|
version (`null` if that upload is no longer on record, or the file was never
|
|
52
52
|
confirmed).
|
|
53
53
|
|
|
54
|
+
`downloadFile`'s URL accepts an HTTP `Range` request header for partial
|
|
55
|
+
reads (`bytes=start-end`, `start-`, or `-suffix`); a satisfiable range answers
|
|
56
|
+
`206` with `Content-Range`, and one past the end of the file answers `416`.
|
|
57
|
+
Both the R2 and local-disk backends support it.
|
|
58
|
+
|
|
54
59
|
Files can sit in folders inside a collection. Pass `path` (for example
|
|
55
60
|
`reports/2026`) when you reserve an upload. `listFiles(collectionId, { path })`
|
|
56
61
|
lists the files directly in that folder, and `listFolders(collectionId, path)`
|
|
57
62
|
lists its subfolders. A folder exists while it holds a file, so there are no
|
|
58
|
-
empty folders. A replacement keeps the file's path;
|
|
59
|
-
|
|
63
|
+
empty folders. A replacement keeps the file's path; `moveFile(id, { path, name,
|
|
64
|
+
version })` changes either or both within the same collection, without
|
|
65
|
+
re-uploading -- moving across collections is not supported. `version` is the
|
|
66
|
+
file's current version (optimistic concurrency, same as a version-pinned
|
|
67
|
+
read): a stale value throws `ApiError` with `status: 409`. A move does not
|
|
68
|
+
change `version`, so two moves at the same version both apply and the last one
|
|
69
|
+
wins. Like an upload, a move may share a path and name with another file.
|
|
70
|
+
|
|
71
|
+
`listFiles(collectionId, { name })` finds a file by name: a case-insensitive
|
|
72
|
+
substring match, composing with `path`, `after` and `limit`. It only ever
|
|
73
|
+
matches files the caller can already list -- it narrows an existing read, it
|
|
74
|
+
never widens one.
|
|
75
|
+
|
|
76
|
+
`deleteFiles(fileIds)` deletes a folder or several files at once: up to 100
|
|
77
|
+
ids, in one transaction, all within the caller's own organisation -- either
|
|
78
|
+
every file is deleted or none is. A duplicate id in the list is collapsed to
|
|
79
|
+
a single delete. An id that does not exist, or that the caller cannot
|
|
80
|
+
delete, refuses the whole batch with the same error a single `deleteFile`
|
|
81
|
+
call for that id would give.
|
|
82
|
+
|
|
83
|
+
`copyFile(fileId, { collectionId, path, name })` copies an existing file's
|
|
84
|
+
bytes to a brand new file, in the same collection (the default) or another
|
|
85
|
+
one in your own organisation, with an optional new path and/or name -- every
|
|
86
|
+
field is optional, and an empty `{}` duplicates the file in place. It counts
|
|
87
|
+
against quota and the upload price exactly like a fresh upload of the same
|
|
88
|
+
bytes, and throws `ApiError` with `status: 409` if the source changes
|
|
89
|
+
mid-copy. Copying a file in another organisation is impossible: it 404s the
|
|
90
|
+
same way any other cross-organisation file read does. Variants and share
|
|
91
|
+
links are never copied.
|
|
60
92
|
|
|
61
93
|
Image files can be resized on demand instead of a tenant running its own
|
|
62
94
|
thumbnail pipeline: `getFileVariant(fileId, width, format)` returns a signed
|
|
@@ -67,6 +99,144 @@ file whose content type `sharp` can decode (JPEG, PNG, WebP, GIF, AVIF)
|
|
|
67
99
|
qualifies -- anything else answers `invalid_request`. A variant's bytes count
|
|
68
100
|
against the same byte quota an upload does.
|
|
69
101
|
|
|
102
|
+
## Audience
|
|
103
|
+
|
|
104
|
+
Every file has an **audience** (issue #71): the list of references, besides
|
|
105
|
+
its owner, who may see it. `reserveUpload`'s `audience` is a plain
|
|
106
|
+
`string[]` of references:
|
|
107
|
+
|
|
108
|
+
| Reference | Who it admits |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `"org"` | Everyone in the organisation who has files read. |
|
|
111
|
+
| `"person:<subject>"` | One signed-in person. |
|
|
112
|
+
| `"team:<teamId>"` | Members of that authz team whose own `files.files:read` grant covers it. |
|
|
113
|
+
| `"group:<app>:<type>/<id>"`, e.g. `"group:chat:room/!abc"` | Members of a group the named app keeps in sync with files. |
|
|
114
|
+
|
|
115
|
+
Omitted, `audience` defaults to `[]` -- **the uploader only**, whether the
|
|
116
|
+
uploader is a signed-in person or an app key. It only applies to a brand new
|
|
117
|
+
file; a `replace` upload keeps the target file's existing audience and
|
|
118
|
+
throws `ApiError` with `status: 400` if `audience` is also given.
|
|
119
|
+
`getFile`/`listFiles` and the rest carry the file's current setting as
|
|
120
|
+
`StoredFile.audience`. The owner changes it afterward with
|
|
121
|
+
`setFileAudience(fileId, { audience, shareable? })`; a holder of
|
|
122
|
+
`files.files:read-any` (below) may also change it -- a caller who only sees
|
|
123
|
+
the file BECAUSE of the audience (a listed person, or a team or group
|
|
124
|
+
member) may not.
|
|
125
|
+
|
|
126
|
+
A `team:`/`group:` reference gives its members **read only**: it narrows
|
|
127
|
+
get/download/a variant/list/folders/the source of a copy/listing shares, but
|
|
128
|
+
never delete, move, replace or share -- a write still needs its own
|
|
129
|
+
permission over the file's real collection, unaffected by the audience.
|
|
130
|
+
|
|
131
|
+
Three shapes this covers:
|
|
132
|
+
- **A DM or private note**: no audience at all (owner only) -- exactly the
|
|
133
|
+
default.
|
|
134
|
+
- **A room's shared files**: `["group:chat:room/!abc"]`, kept in sync by the
|
|
135
|
+
room's own app through a group-membership API (a later addition to this
|
|
136
|
+
SDK); every current member reads the room's files without files granting
|
|
137
|
+
them anything itself.
|
|
138
|
+
- **A personal-files area a support team can see**: `["team:<teamId>"]` --
|
|
139
|
+
the person's own files, readable by anyone on that team whose grant covers
|
|
140
|
+
it, still only ever deletable/replaceable by the owner or a
|
|
141
|
+
`files.files:replace` grant scoped directly to the file (never merely by
|
|
142
|
+
being on the team).
|
|
143
|
+
|
|
144
|
+
### Groups
|
|
145
|
+
|
|
146
|
+
A group is owned by the app that created it (`group:<app>:...`), not by
|
|
147
|
+
Files: Files only checks membership, so a read never waits on the owning
|
|
148
|
+
app. Chat, say, creates `group:chat:room/!abc` and keeps its membership in
|
|
149
|
+
sync as its own room changes; any file whose `audience` names that ref
|
|
150
|
+
admits every current member to a read, nothing more.
|
|
151
|
+
|
|
152
|
+
Every write needs `files.groups:update` (granted to no built-in role by
|
|
153
|
+
default -- groups are app-owned, so only the owning app's own key holds
|
|
154
|
+
it), and is refused unless that key's own `--app` (set once, at
|
|
155
|
+
`keys:issue --app <slug>`, apps/host's README) names the SAME app the
|
|
156
|
+
group ref itself names -- chat's key can write `group:chat:...`, never
|
|
157
|
+
`group:lokessmie:...`. A human is always refused. This ownership rule is
|
|
158
|
+
provisional and may change. Reads need `files.groups:read` and carry no
|
|
159
|
+
such restriction.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const group = await client.createGroup('group:chat:room/!abc'); // idempotent
|
|
163
|
+
await client.setGroupMembers('group:chat:room/!abc', [
|
|
164
|
+
{ class: 'human', id: personId },
|
|
165
|
+
]); // replaces the full list atomically, never a merge
|
|
166
|
+
await client.addGroupMember('group:chat:room/!abc', { class: 'human', id: otherPersonId }); // idempotent
|
|
167
|
+
await client.removeGroupMember('group:chat:room/!abc', { class: 'human', id: personId }); // idempotent
|
|
168
|
+
const members = await client.listGroupMembers('group:chat:room/!abc');
|
|
169
|
+
await client.deleteGroup('group:chat:room/!abc'); // idempotent, cascades to its members
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Deleting a group never touches any file's `audience`: a file that named it
|
|
173
|
+
simply stops matching, the same way removing a member does.
|
|
174
|
+
|
|
175
|
+
### Shareable links
|
|
176
|
+
|
|
177
|
+
Each file also carries `shareable: boolean`. `createShare` refuses a file
|
|
178
|
+
whose `shareable` is `false`; a share link stops resolving the moment its
|
|
179
|
+
file's `shareable` turns `false`, or its creator drops out of the audience,
|
|
180
|
+
whichever comes first -- both checked fresh on every redemption. Omitted at
|
|
181
|
+
upload time, `shareable` defaults to `false` for a signed-in person (a link
|
|
182
|
+
is an extra, deliberate step) and `true` for any other principal class (an
|
|
183
|
+
app key), so lokessmie's and offline.tf's existing app-key integrations keep
|
|
184
|
+
minting links unchanged. Change it afterward with `setFileAudience`'s own
|
|
185
|
+
`shareable` field.
|
|
186
|
+
|
|
187
|
+
### Seeing everything: `files.files:read-any`
|
|
188
|
+
|
|
189
|
+
A sensitive permission: read any file in the organisation, whatever its
|
|
190
|
+
audience. Every get/download/variant/list/folders/copy-source/share-listing
|
|
191
|
+
call still needs its own base permission too UNLESS the caller holds
|
|
192
|
+
read_any, in which case that alone suffices -- an auditor or devops role
|
|
193
|
+
granted only read_any needs no separate `files.files:read`/
|
|
194
|
+
`files.files:download`/`files.shares:read` grant to use it. Granted by
|
|
195
|
+
default to the built-in admin role (revocable per organisation, or
|
|
196
|
+
grantable to another role) and, unconditionally, to every app key -- the
|
|
197
|
+
same continuity `shareable`'s own app-key default protects. Every read
|
|
198
|
+
admitted ONLY by read_any writes its own audit row (a distinct action per
|
|
199
|
+
underlying permission, e.g. `files.file_read_privileged`), and a write
|
|
200
|
+
(move, replace, delete, share, an audience change) that read_any alone
|
|
201
|
+
admitted writes one too, alongside its ordinary action.
|
|
202
|
+
|
|
203
|
+
Whatever the caller's role, an audience or read_any check that fails throws
|
|
204
|
+
the same `ApiError` a caller lacking the underlying permission outright
|
|
205
|
+
would get -- a file hidden this way is never distinguishable from one the
|
|
206
|
+
caller was never granted access to at all.
|
|
207
|
+
|
|
208
|
+
### My files, Shared with me, and search (issue #72)
|
|
209
|
+
|
|
210
|
+
Two cross-collection, cursor-paginated lists for a signed-in caller's own
|
|
211
|
+
"files" view, e.g. Agora's:
|
|
212
|
+
|
|
213
|
+
- `listMyFiles({ after, limit, name })`: every file the caller owns, across
|
|
214
|
+
every collection in the organisation, newest first.
|
|
215
|
+
- `listSharedFiles({ after, limit, name })`: every file the caller did NOT
|
|
216
|
+
upload but may read through its audience -- `'org'`, a `'person:'`
|
|
217
|
+
reference naming them, a `'team:'` reference their own read grant covers,
|
|
218
|
+
or a `'group:'` reference they belong to. Each result is a `StoredFile`
|
|
219
|
+
plus its `owner`.
|
|
220
|
+
|
|
221
|
+
Both return `{ files, next }`; pass a page's `next` back as `after` to fetch
|
|
222
|
+
the next one, `next: null` meaning the last page. `name`, like `listFiles`'s
|
|
223
|
+
own, is a case-insensitive substring match, with the caller's own
|
|
224
|
+
`%`/`_`/`\` matched literally rather than as ILIKE wildcards. There is no
|
|
225
|
+
separate "search everywhere" endpoint: since a file is always either the
|
|
226
|
+
caller's own or someone else's, searching both lists with the same `name`
|
|
227
|
+
and merging the results covers it -- Agora's two lists already need the
|
|
228
|
+
filter for their own search boxes, so a third endpoint would only
|
|
229
|
+
duplicate what calling both already gives.
|
|
230
|
+
|
|
231
|
+
`listSharedFiles` never includes a file only `files.files:read-any` would
|
|
232
|
+
admit -- unlike `listFiles`/`getFile`, which do, and audit it. Read-any is
|
|
233
|
+
an audit-grade capability to read any file in the organisation; folding it
|
|
234
|
+
into "Shared with me" would turn an ordinary person's own list into a full
|
|
235
|
+
organisation directory the moment they held it, with no separate audit
|
|
236
|
+
trail the way every other read-any bypass gets. A caller that wants
|
|
237
|
+
everything, read-any included, is `listFiles`/`listFolders` themselves
|
|
238
|
+
(per collection), unchanged by this feature.
|
|
239
|
+
|
|
70
240
|
The root export provides the client and public request/response types.
|
|
71
241
|
`@wtfalch/files/client` is also supported. There are no server, database,
|
|
72
242
|
migration, provider-adapter or authorization-engine exports. The service
|
|
@@ -75,6 +245,43 @@ implementation is maintained in a private repository.
|
|
|
75
245
|
The first 0.1.0 SDK targets the new service API. Production migration from Valet
|
|
76
246
|
is a separate rollout; installing the SDK does not migrate accounts or files.
|
|
77
247
|
|
|
248
|
+
## Calling from a browser through a same-origin proxy
|
|
249
|
+
|
|
250
|
+
The files host itself has no CORS headers and no public domain (task #70):
|
|
251
|
+
`credential()` above must never be a privileged service credential in
|
|
252
|
+
browser code, and a browser cannot reach the host directly either way. A
|
|
253
|
+
browser app such as Agora instead runs its own same-origin proxy -- a route
|
|
254
|
+
on the app's own origin that forwards `Authorization` and
|
|
255
|
+
`X-Organisation-Id` to the files host on its private network, stripping any
|
|
256
|
+
cookies -- and points the SDK at itself:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const files = createFilesClient({
|
|
260
|
+
url: window.location.origin,
|
|
261
|
+
// The person's own @wtfalch/auth access token, read fresh on every call
|
|
262
|
+
// rather than captured once -- the same shape Agora already passes to
|
|
263
|
+
// its calendar client's `getAccessToken`.
|
|
264
|
+
credential: () => accessTokenRef.current,
|
|
265
|
+
organisationId, // the person's files organisation
|
|
266
|
+
// Only needed if the proxy is mounted under a path prefix, e.g. `/_files`
|
|
267
|
+
// -- the SDK always builds request paths from the origin's root
|
|
268
|
+
// (`/v1/...`), so a prefixed proxy needs its path put back before the
|
|
269
|
+
// real request leaves the browser.
|
|
270
|
+
fetch: (input, init) => {
|
|
271
|
+
const target = new URL(input instanceof Request ? input.url : input);
|
|
272
|
+
target.pathname = `/_files${target.pathname}`;
|
|
273
|
+
return fetch(target, init);
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The files host verifies that access token itself -- see `@wtfalch/files-host`'s
|
|
279
|
+
README, "Person tokens (Agora and other browser callers)" -- and acts as
|
|
280
|
+
that person in `organisationId`, subject to their real membership there. A
|
|
281
|
+
credential this SDK cannot turn into a bearer at all has no separate
|
|
282
|
+
transport option here: `credential()` already sends whatever it returns as
|
|
283
|
+
`Authorization: Bearer`, machine key or person token alike.
|
|
284
|
+
|
|
78
285
|
## Migrating from `@wtfalch/storage`
|
|
79
286
|
|
|
80
287
|
`@wtfalch/storage` is this same SDK under its pre-rename name (the 2026-09-20
|
|
@@ -137,6 +344,10 @@ for, so a reader falls back to the id.
|
|
|
137
344
|
host; use `updateOrganisationCeiling()` to change them. Byte and money totals
|
|
138
345
|
remain decimal strings, including values above JavaScript's safe integer range.
|
|
139
346
|
|
|
347
|
+
Pass `name` and `owner` (a subject id) to onboard Agora for a new organisation.
|
|
348
|
+
The 201 result then carries `agoraKey: { id, secret }` once. Store the secret at
|
|
349
|
+
once: a repeat call returns 200 without it.
|
|
350
|
+
|
|
140
351
|
For the operator application's existing transport, compose the SDK on the server:
|
|
141
352
|
|
|
142
353
|
```ts
|
|
@@ -154,3 +365,97 @@ if (!result.created) {
|
|
|
154
365
|
`allowInsecureHttp` should come from trusted deployment configuration, never a
|
|
155
366
|
request parameter. Keep application permission checks inside the supplied fetch
|
|
156
367
|
and retain the UI's error mapping when replacing a custom client with the SDK.
|
|
368
|
+
|
|
369
|
+
### Deleting an organisation
|
|
370
|
+
|
|
371
|
+
`deleteOrganisation(organisationId)` deletes an organisation with a 7-day
|
|
372
|
+
grace period: it suspends the organisation immediately (every tenant write
|
|
373
|
+
and public share link is refused straight away, the same as a ceiling-update
|
|
374
|
+
suspension) and marks it deleted. `restoreOrganisation(organisationId)` undoes
|
|
375
|
+
that within the 7 days, clearing the suspension the delete itself set back to
|
|
376
|
+
whatever the organisation's suspension was before -- an operator suspension
|
|
377
|
+
set on purpose earlier is never silently cleared by a restore. Both need
|
|
378
|
+
`files.organisations:delete`, a permission of its own that
|
|
379
|
+
`files.organisations:provision` does not include.
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
const deleted = await management.deleteOrganisation(organisationId);
|
|
383
|
+
// deleted.deletedAt is the ISO timestamp the grace period counts from.
|
|
384
|
+
await management.restoreOrganisation(organisationId); // any time in the next 7 days
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Deleting is idempotent: deleting an organisation that is already deleted
|
|
388
|
+
succeeds again, unchanged, rather than throwing, so a caller that never saw a
|
|
389
|
+
first call's response (a dropped connection, a timeout) can always retry.
|
|
390
|
+
Deleting one that was never provisioned throws `ApiError` with `status: 404`.
|
|
391
|
+
Once the grace period passes, a daily job purges the organisation for good --
|
|
392
|
+
every row and every stored object -- and `restoreOrganisation` then throws
|
|
393
|
+
`ApiError` with `status: 404` too, the same as for an organisation that was
|
|
394
|
+
never deleted; the two are not distinguishable from the client, by design.
|
|
395
|
+
|
|
396
|
+
### Membership
|
|
397
|
+
|
|
398
|
+
The product that owns its users (Agora, lokessmie) writes memberships in its
|
|
399
|
+
own files organisation -- files never auto-admits a signed-in person by token
|
|
400
|
+
alone. `subject` is always the person's token subject (a `human` principal);
|
|
401
|
+
`role` is a role key, either the built-in `files_member` role (reads, uploads,
|
|
402
|
+
downloads and shares their own files -- the role a product assigns to an
|
|
403
|
+
ordinary person) or a custom role the organisation defined. Every call needs
|
|
404
|
+
`files.members:read` (list) or `files.members:update` (add/change role/remove),
|
|
405
|
+
held by the calling key for its own organisation only -- `files.members:update`
|
|
406
|
+
is sensitive: a key can never grant or take away a role wider than its own
|
|
407
|
+
authority already covers, so a files-scoped product key can grant
|
|
408
|
+
`files_member` but not, say, `admin` (which also needs billing/tenant
|
|
409
|
+
permissions no files-only key holds).
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
const { members } = await management.listMembers();
|
|
413
|
+
const added = await management.addMember({ subject: personId, role: 'files_member' });
|
|
414
|
+
await management.setMemberRole(personId, 'c_read_only'); // a custom role the org defined
|
|
415
|
+
await management.removeMember(personId);
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`addMember` is idempotent: naming a subject who is already a member returns
|
|
419
|
+
their current row unchanged (`role` in the input is ignored in that case) --
|
|
420
|
+
use `setMemberRole` to change an existing member's role. `removeMember` is
|
|
421
|
+
idempotent too: removing a subject who is not, or no longer, a member still
|
|
422
|
+
succeeds. Both, like `setMemberRole`, throw `ApiError` with `status: 422` if
|
|
423
|
+
the calling key cannot delegate the role involved.
|
|
424
|
+
|
|
425
|
+
## Webhooks
|
|
426
|
+
|
|
427
|
+
`management.setWebhook(url)` sets the single outbound endpoint for the
|
|
428
|
+
caller's organisation; `management.setWebhook(null)` clears it and stops
|
|
429
|
+
delivery. `url` must be `https://`, under 2048 characters, with no embedded
|
|
430
|
+
credentials, and not point at an obviously local or private address (this is
|
|
431
|
+
a syntactic check only, not DNS-resolution-time SSRF hardening). A successful
|
|
432
|
+
set returns a fresh `secret`, generated on every call -- this is also how you
|
|
433
|
+
rotate it. `secret` is returned only from `setWebhook()`; `management.getWebhook()`
|
|
434
|
+
returns just the current `url`. Both calls require the same permission as the
|
|
435
|
+
tenant's quota and budget calls.
|
|
436
|
+
|
|
437
|
+
Four event types are delivered, each as a JSON POST body of the shape
|
|
438
|
+
`{ type, occurredAt, organisationId, data }`:
|
|
439
|
+
|
|
440
|
+
- `files.upload_confirmed`, sent after an upload confirms, with `data`:
|
|
441
|
+
`fileId`, `collectionId`, `bytes`, `contentType`, `checksum`, `sha256` and
|
|
442
|
+
`principalRef` (the confirming principal).
|
|
443
|
+
- `files.quota_near_ceiling`, sent when a fresh read of the organisation's
|
|
444
|
+
committed bytes (used plus reserved) reaches 90% of its effective cap, with
|
|
445
|
+
`data`: `usedBytes`, `reservedBytes` and `ceilingBytes`.
|
|
446
|
+
- `files.file_deleted`, sent after `deleteFile()` commits, with `data`:
|
|
447
|
+
`fileId`, `collectionId` and `version` (the version at deletion).
|
|
448
|
+
- `files.variant_created`, sent when `getFileVariant()` generates and stores a
|
|
449
|
+
new resized copy (not on a cache hit), with `data`: `fileId`, `version`,
|
|
450
|
+
`width`, `format` and `bytes`.
|
|
451
|
+
|
|
452
|
+
Every request carries an `x-files-webhook-signature: sha256=<hex>` header,
|
|
453
|
+
the HMAC-SHA256 of the exact raw request body using the `secret` from
|
|
454
|
+
`setWebhook()`. Verify it by recomputing that HMAC over the raw body you
|
|
455
|
+
received and comparing it to the header before trusting the payload.
|
|
456
|
+
|
|
457
|
+
Delivery is best effort: it never retries, times out after 5 seconds, and a
|
|
458
|
+
failed or unreachable endpoint never fails the upload or quota check that
|
|
459
|
+
triggered it. There is no persistent retry queue, so a receiver must treat
|
|
460
|
+
delivery as at-most-once (or not-at-all) and reconcile through the regular
|
|
461
|
+
API when a delivery is missed.
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ClientOptions, type ManagementClientOptions } from './transport.js';
|
|
2
|
-
import type { AuditPage, AuditQuery, BudgetSummary, CreatedShare, FilesRate, OrganisationShare, OrganisationSummary, ProvisionOrganisationInput, ProvisionOrganisationResult, QuotaSummary, SetRateInput, SetWebhookResult, Share, StoredFile, UpdateOrganisationCeilingInput, UploadInput, UploadReservation, UploadedPart, UsageDay, VariantFormat, VariantWidth, WebhookConfig } from './types.js';
|
|
2
|
+
import type { AddMemberInput, AuditPage, AuditQuery, BudgetSummary, CopyFileInput, CreatedShare, FilesPage, FilesRate, Group, GroupMember, Member, MoveFileInput, OrganisationShare, OrganisationSummary, ProvisionOrganisationInput, ProvisionOrganisationResult, QuotaSummary, SetFileAudienceInput, SetRateInput, SetWebhookResult, Share, SharedFilesPage, StoredFile, UpdateOrganisationCeilingInput, UploadInput, UploadReservation, UploadedPart, UsageDay, VariantFormat, VariantWidth, WebhookConfig } from './types.js';
|
|
3
3
|
export { ApiError, type ClientOptions, type ManagementClientOptions } from './transport.js';
|
|
4
4
|
export declare function createFilesClient(options: ClientOptions): {
|
|
5
5
|
createCollection: (name: string) => Promise<{
|
|
@@ -37,18 +37,77 @@ export declare function createFilesClient(options: ClientOptions): {
|
|
|
37
37
|
expiresIn: number;
|
|
38
38
|
}>;
|
|
39
39
|
deleteFile: (id: string) => Promise<void>;
|
|
40
|
-
/**
|
|
40
|
+
/** Copies an existing file's bytes to a brand new file (task F), in the
|
|
41
|
+
* same collection (the default) or another one in your own
|
|
42
|
+
* organisation, with an optional new path and/or name. Counts against
|
|
43
|
+
* quota and the upload price exactly like a fresh upload of the same
|
|
44
|
+
* bytes; throws `ApiError` with `status: 409` if the source changes
|
|
45
|
+
* mid-copy. Variants and share links are never copied. */
|
|
46
|
+
copyFile: (id: string, input?: CopyFileInput) => Promise<StoredFile>;
|
|
47
|
+
/** Deletes up to 100 files, in the caller's own organisation, in one
|
|
48
|
+
* transaction: either every file goes or none does. A duplicate id
|
|
49
|
+
* collapses to a single delete. An id that does not exist or is not
|
|
50
|
+
* permitted refuses the whole batch with the same error a single
|
|
51
|
+
* `deleteFile` of that id would give. */
|
|
52
|
+
deleteFiles: (fileIds: string[]) => Promise<void>;
|
|
53
|
+
/** Change `path` and/or `name` within the same collection, without
|
|
54
|
+
* re-uploading (task D). `version` is optimistic concurrency: a stale
|
|
55
|
+
* value, or a target `path`+`name` another file already occupies, both
|
|
56
|
+
* throw `ApiError` with `status: 409`. */
|
|
57
|
+
moveFile: (id: string, input: MoveFileInput) => Promise<StoredFile>;
|
|
58
|
+
/** Change a file's audience and/or its `shareable` flag (issue #71).
|
|
59
|
+
* Only the file's owner, or a holder of `files.files:read-any`, may
|
|
60
|
+
* call this -- listed people, team or group members may not, even with
|
|
61
|
+
* a broader `files.files:replace` grant. */
|
|
62
|
+
setFileAudience: (id: string, input: SetFileAudienceInput) => Promise<StoredFile>;
|
|
63
|
+
/** `path` lists only the files directly in that folder. `name` is a
|
|
64
|
+
* case-insensitive substring match against the file name. */
|
|
41
65
|
listFiles: (collection: string, page?: {
|
|
42
66
|
after?: string;
|
|
43
67
|
limit?: number;
|
|
44
68
|
path?: string;
|
|
69
|
+
name?: string;
|
|
45
70
|
}) => Promise<StoredFile[]>;
|
|
46
71
|
/** The immediate subfolders of `path` ('' is the root) that hold at least
|
|
47
72
|
* one file the caller can read. */
|
|
48
73
|
listFolders: (collection: string, path?: string) => Promise<string[]>;
|
|
74
|
+
/** Agora's "My files" (issue #72): every file the caller owns, across
|
|
75
|
+
* every collection in the organisation, newest first. `name`, like
|
|
76
|
+
* `listFiles`'s own, is a case-insensitive substring match. Pass a
|
|
77
|
+
* page's `next` back as `after` to fetch the next one; `next: null`
|
|
78
|
+
* means this was the last page. */
|
|
79
|
+
listMyFiles: (page?: {
|
|
80
|
+
after?: string;
|
|
81
|
+
limit?: number;
|
|
82
|
+
name?: string;
|
|
83
|
+
}) => Promise<FilesPage>;
|
|
84
|
+
/** Agora's "Shared with me" (issue #72): every file the caller did NOT
|
|
85
|
+
* upload but may read through its `audience` -- 'org', a 'person:'
|
|
86
|
+
* reference naming them, a 'team:' reference their own read grant
|
|
87
|
+
* covers, or a 'group:' reference they belong to -- newest first, each
|
|
88
|
+
* with its `owner`. Never includes a file only `files.files:read-any`
|
|
89
|
+
* would admit: this is what the caller ordinarily reads, not an audit
|
|
90
|
+
* view of the whole organisation. */
|
|
91
|
+
listSharedFiles: (page?: {
|
|
92
|
+
after?: string;
|
|
93
|
+
limit?: number;
|
|
94
|
+
name?: string;
|
|
95
|
+
}) => Promise<SharedFilesPage>;
|
|
49
96
|
createShare: (fileId: string, expiresAt: string | null) => Promise<CreatedShare>;
|
|
50
97
|
listShares: (fileId: string) => Promise<Share[]>;
|
|
51
98
|
revokeShare: (shareId: string) => Promise<void>;
|
|
99
|
+
/** Idempotent: a group ref that already exists is returned unchanged. */
|
|
100
|
+
createGroup: (groupRef: string) => Promise<Group>;
|
|
101
|
+
getGroup: (groupRef: string) => Promise<Group>;
|
|
102
|
+
/** Idempotent: a group ref that does not exist is a no-op. */
|
|
103
|
+
deleteGroup: (groupRef: string) => Promise<void>;
|
|
104
|
+
listGroupMembers: (groupRef: string) => Promise<GroupMember[]>;
|
|
105
|
+
/** Replaces the group's full member list atomically -- never a merge. */
|
|
106
|
+
setGroupMembers: (groupRef: string, members: GroupMember[]) => Promise<GroupMember[]>;
|
|
107
|
+
/** Idempotent: a member already present is a no-op. */
|
|
108
|
+
addGroupMember: (groupRef: string, member: GroupMember) => Promise<void>;
|
|
109
|
+
/** Idempotent: a member not present is a no-op. */
|
|
110
|
+
removeGroupMember: (groupRef: string, member: GroupMember) => Promise<void>;
|
|
52
111
|
};
|
|
53
112
|
/** Operator and tenant management routes. `options.fetch` authenticates
|
|
54
113
|
* each request with a signed human proof or an issued management key;
|
|
@@ -64,6 +123,8 @@ export declare function createFilesManagementClient(options: ManagementClientOpt
|
|
|
64
123
|
}>;
|
|
65
124
|
provisionOrganisation: (input: ProvisionOrganisationInput) => Promise<ProvisionOrganisationResult>;
|
|
66
125
|
updateOrganisationCeiling: (organisationId: string, input: UpdateOrganisationCeilingInput) => Promise<OrganisationSummary>;
|
|
126
|
+
deleteOrganisation: (organisationId: string) => Promise<OrganisationSummary>;
|
|
127
|
+
restoreOrganisation: (organisationId: string) => Promise<OrganisationSummary>;
|
|
67
128
|
getQuota: () => Promise<QuotaSummary>;
|
|
68
129
|
setQuota: (limitBytes: string | null) => Promise<QuotaSummary>;
|
|
69
130
|
setKeyQuota: (keyId: string, limitBytes: string | null) => Promise<QuotaSummary>;
|
|
@@ -78,4 +139,13 @@ export declare function createFilesManagementClient(options: ManagementClientOpt
|
|
|
78
139
|
}>;
|
|
79
140
|
getWebhook: () => Promise<WebhookConfig>;
|
|
80
141
|
setWebhook: (url: string | null) => Promise<SetWebhookResult>;
|
|
142
|
+
listMembers: () => Promise<{
|
|
143
|
+
members: Member[];
|
|
144
|
+
}>;
|
|
145
|
+
addMember: (input: AddMemberInput) => Promise<Member>;
|
|
146
|
+
setMemberRole: (subject: string, role: string) => Promise<Member>;
|
|
147
|
+
removeMember: (subject: string) => Promise<{
|
|
148
|
+
subject: string;
|
|
149
|
+
removed: boolean;
|
|
150
|
+
}>;
|
|
81
151
|
};
|
package/dist/client.js
CHANGED
|
@@ -34,21 +34,86 @@ export function createFilesClient(options) {
|
|
|
34
34
|
// one of `VARIANT_WIDTHS`; the service answers 400 otherwise.
|
|
35
35
|
getFileVariant: (id, width, format) => call('GET', `/v1/files/${encodeURIComponent(id)}/variants/${width}.${format}`),
|
|
36
36
|
deleteFile: (id) => call('DELETE', `/v1/files/${encodeURIComponent(id)}`),
|
|
37
|
-
/**
|
|
37
|
+
/** Copies an existing file's bytes to a brand new file (task F), in the
|
|
38
|
+
* same collection (the default) or another one in your own
|
|
39
|
+
* organisation, with an optional new path and/or name. Counts against
|
|
40
|
+
* quota and the upload price exactly like a fresh upload of the same
|
|
41
|
+
* bytes; throws `ApiError` with `status: 409` if the source changes
|
|
42
|
+
* mid-copy. Variants and share links are never copied. */
|
|
43
|
+
copyFile: (id, input = {}) => call('POST', `/v1/files/${encodeURIComponent(id)}/copy`, input),
|
|
44
|
+
/** Deletes up to 100 files, in the caller's own organisation, in one
|
|
45
|
+
* transaction: either every file goes or none does. A duplicate id
|
|
46
|
+
* collapses to a single delete. An id that does not exist or is not
|
|
47
|
+
* permitted refuses the whole batch with the same error a single
|
|
48
|
+
* `deleteFile` of that id would give. */
|
|
49
|
+
deleteFiles: (fileIds) => call('DELETE', '/v1/files', { fileIds }),
|
|
50
|
+
/** Change `path` and/or `name` within the same collection, without
|
|
51
|
+
* re-uploading (task D). `version` is optimistic concurrency: a stale
|
|
52
|
+
* value, or a target `path`+`name` another file already occupies, both
|
|
53
|
+
* throw `ApiError` with `status: 409`. */
|
|
54
|
+
moveFile: (id, input) => call('PATCH', `/v1/files/${encodeURIComponent(id)}`, input),
|
|
55
|
+
/** Change a file's audience and/or its `shareable` flag (issue #71).
|
|
56
|
+
* Only the file's owner, or a holder of `files.files:read-any`, may
|
|
57
|
+
* call this -- listed people, team or group members may not, even with
|
|
58
|
+
* a broader `files.files:replace` grant. */
|
|
59
|
+
setFileAudience: (id, input) => call('PATCH', `/v1/files/${encodeURIComponent(id)}/audience`, input),
|
|
60
|
+
/** `path` lists only the files directly in that folder. `name` is a
|
|
61
|
+
* case-insensitive substring match against the file name. */
|
|
38
62
|
listFiles: (collection, page = {}) => call('GET', `/v1/collections/${encodeURIComponent(collection)}/files?${new URLSearchParams({
|
|
39
63
|
...(page.after ? { after: page.after } : {}),
|
|
40
64
|
...(page.limit !== undefined ? { limit: String(page.limit) } : {}),
|
|
41
65
|
...(page.path !== undefined ? { path: page.path } : {}),
|
|
66
|
+
...(page.name !== undefined ? { name: page.name } : {}),
|
|
42
67
|
})}`),
|
|
43
68
|
/** The immediate subfolders of `path` ('' is the root) that hold at least
|
|
44
69
|
* one file the caller can read. */
|
|
45
70
|
listFolders: (collection, path = '') => call('GET', `/v1/collections/${encodeURIComponent(collection)}/folders?${new URLSearchParams({ path })}`).then((result) => result.folders),
|
|
71
|
+
/** Agora's "My files" (issue #72): every file the caller owns, across
|
|
72
|
+
* every collection in the organisation, newest first. `name`, like
|
|
73
|
+
* `listFiles`'s own, is a case-insensitive substring match. Pass a
|
|
74
|
+
* page's `next` back as `after` to fetch the next one; `next: null`
|
|
75
|
+
* means this was the last page. */
|
|
76
|
+
listMyFiles: (page = {}) => call('GET', `/v1/files/mine?${new URLSearchParams({
|
|
77
|
+
...(page.after ? { after: page.after } : {}),
|
|
78
|
+
...(page.limit !== undefined ? { limit: String(page.limit) } : {}),
|
|
79
|
+
...(page.name !== undefined ? { name: page.name } : {}),
|
|
80
|
+
})}`),
|
|
81
|
+
/** Agora's "Shared with me" (issue #72): every file the caller did NOT
|
|
82
|
+
* upload but may read through its `audience` -- 'org', a 'person:'
|
|
83
|
+
* reference naming them, a 'team:' reference their own read grant
|
|
84
|
+
* covers, or a 'group:' reference they belong to -- newest first, each
|
|
85
|
+
* with its `owner`. Never includes a file only `files.files:read-any`
|
|
86
|
+
* would admit: this is what the caller ordinarily reads, not an audit
|
|
87
|
+
* view of the whole organisation. */
|
|
88
|
+
listSharedFiles: (page = {}) => call('GET', `/v1/files/shared?${new URLSearchParams({
|
|
89
|
+
...(page.after ? { after: page.after } : {}),
|
|
90
|
+
...(page.limit !== undefined ? { limit: String(page.limit) } : {}),
|
|
91
|
+
...(page.name !== undefined ? { name: page.name } : {}),
|
|
92
|
+
})}`),
|
|
46
93
|
// Public share links (task S6): `files.files:share`/`files.shares:read`,
|
|
47
94
|
// bearer key or signed request -- both reach the same /v1/files/*
|
|
48
95
|
// routes this client already calls.
|
|
49
96
|
createShare: (fileId, expiresAt) => call('POST', `/v1/files/${encodeURIComponent(fileId)}/shares`, { expiresAt }),
|
|
50
97
|
listShares: (fileId) => call('GET', `/v1/files/${encodeURIComponent(fileId)}/shares`).then((r) => r.shares),
|
|
51
98
|
revokeShare: (shareId) => call('DELETE', `/v1/shares/${encodeURIComponent(shareId)}`),
|
|
99
|
+
// App-owned groups (task G, campaign 2026-09-25-agora-files):
|
|
100
|
+
// `files.groups:read`/`files.groups:update`, granted only to the app
|
|
101
|
+
// keys that need them (groups are app-owned, no built-in role holds
|
|
102
|
+
// either). A write is refused (403) unless the calling key's own
|
|
103
|
+
// `--app` (apps/host README) names the group ref's own <app> segment;
|
|
104
|
+
// a human is always refused. This ownership rule is provisional.
|
|
105
|
+
/** Idempotent: a group ref that already exists is returned unchanged. */
|
|
106
|
+
createGroup: (groupRef) => call('POST', '/v1/groups', { groupRef }),
|
|
107
|
+
getGroup: (groupRef) => call('GET', `/v1/groups/${encodeURIComponent(groupRef)}`),
|
|
108
|
+
/** Idempotent: a group ref that does not exist is a no-op. */
|
|
109
|
+
deleteGroup: (groupRef) => call('DELETE', `/v1/groups/${encodeURIComponent(groupRef)}`),
|
|
110
|
+
listGroupMembers: (groupRef) => call('GET', `/v1/groups/${encodeURIComponent(groupRef)}/members`).then((r) => r.members),
|
|
111
|
+
/** Replaces the group's full member list atomically -- never a merge. */
|
|
112
|
+
setGroupMembers: (groupRef, members) => call('PUT', `/v1/groups/${encodeURIComponent(groupRef)}/members`, { members }).then((r) => r.members),
|
|
113
|
+
/** Idempotent: a member already present is a no-op. */
|
|
114
|
+
addGroupMember: (groupRef, member) => call('POST', `/v1/groups/${encodeURIComponent(groupRef)}/members`, member),
|
|
115
|
+
/** Idempotent: a member not present is a no-op. */
|
|
116
|
+
removeGroupMember: (groupRef, member) => call('DELETE', `/v1/groups/${encodeURIComponent(groupRef)}/members/${encodeURIComponent(`${member.class}:${member.id}`)}`),
|
|
52
117
|
};
|
|
53
118
|
}
|
|
54
119
|
/** Operator and tenant management routes. `options.fetch` authenticates
|
|
@@ -75,6 +140,17 @@ export function createFilesManagementClient(options) {
|
|
|
75
140
|
return { ...data, created: status === 201 };
|
|
76
141
|
},
|
|
77
142
|
updateOrganisationCeiling: (organisationId, input) => call('PATCH', `/v1/manage/organisations/${encodeURIComponent(organisationId)}`, input),
|
|
143
|
+
// Delete an organisation with a 7-day grace period (task O, issue #67).
|
|
144
|
+
// Idempotent: deleting an already-deleted organisation succeeds again
|
|
145
|
+
// (unchanged `deletedAt`, not a fresh clock); deleting one that was
|
|
146
|
+
// never provisioned throws `ApiError` with `status: 404`.
|
|
147
|
+
deleteOrganisation: (organisationId) => call('DELETE', `/v1/manage/organisations/${encodeURIComponent(organisationId)}`),
|
|
148
|
+
// Undoes deleteOrganisation() within its 7-day grace period; throws
|
|
149
|
+
// `ApiError` with `status: 404` once that period has passed (whether or
|
|
150
|
+
// not the organisation has actually been purged yet) or for one that
|
|
151
|
+
// was never deleted. Requires the same `files.organisations:delete`
|
|
152
|
+
// permission as deleteOrganisation() -- there is no separate grant.
|
|
153
|
+
restoreOrganisation: (organisationId) => call('POST', `/v1/manage/organisations/${encodeURIComponent(organisationId)}/restore`),
|
|
78
154
|
// Tenant routes (task S4b): the caller's own organisation, from the
|
|
79
155
|
// signed request's claim -- there is no organisation id to pass here.
|
|
80
156
|
getQuota: () => call('GET', '/v1/manage/quota'),
|
|
@@ -94,5 +170,17 @@ export function createFilesManagementClient(options) {
|
|
|
94
170
|
// Outbound event delivery (task gap-36). setWebhook(null) disables it.
|
|
95
171
|
getWebhook: () => call('GET', '/v1/manage/webhook'),
|
|
96
172
|
setWebhook: (url) => call('PUT', '/v1/manage/webhook', { url }),
|
|
173
|
+
// Membership (task M): the product that owns its users writes
|
|
174
|
+
// memberships in its own files organisation. `files.members:read`/
|
|
175
|
+
// `files.members:update`, held by the calling key for its own
|
|
176
|
+
// organisation only.
|
|
177
|
+
listMembers: () => call('GET', '/v1/manage/members'),
|
|
178
|
+
// Idempotent: adding a subject who is already a member returns their
|
|
179
|
+
// current row unchanged; use setMemberRole to change it.
|
|
180
|
+
addMember: (input) => call('POST', '/v1/manage/members', input),
|
|
181
|
+
setMemberRole: (subject, role) => call('PATCH', `/v1/manage/members/${encodeURIComponent(subject)}`, { role }),
|
|
182
|
+
// Idempotent: removing a subject who is not (or no longer) a member
|
|
183
|
+
// still succeeds.
|
|
184
|
+
removeMember: (subject) => call('DELETE', `/v1/manage/members/${encodeURIComponent(subject)}`),
|
|
97
185
|
};
|
|
98
186
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/** Who besides `files.files:read-any` may see a file (issue #71): each entry
|
|
2
|
+
* is one reference --
|
|
3
|
+
* - `"org"`: everyone in the organisation who has files read.
|
|
4
|
+
* - `"person:<subject>"`: one signed-in person.
|
|
5
|
+
* - `"team:<teamId>"`: members of that authz team whose own
|
|
6
|
+
* `files.files:read` grant covers it -- read only, never delete, move,
|
|
7
|
+
* replace or share.
|
|
8
|
+
* - `"group:<app>:<type>/<id>"`, e.g. `"group:chat:room/!abc"`: members of a
|
|
9
|
+
* group the named app keeps in sync with files -- also read only.
|
|
10
|
+
*
|
|
11
|
+
* The owner is always admitted, whether or not the array names them. Set on
|
|
12
|
+
* upload (`UploadInput.audience`, default `[]` -- owner only) and changed
|
|
13
|
+
* afterward with `setFileAudience`; see packages/sdk/README.md's Audience
|
|
14
|
+
* section for the DM/room/personal-files examples and how groups and
|
|
15
|
+
* `files.files:read-any` fit in. */
|
|
16
|
+
export type FileAudience = string[];
|
|
1
17
|
/** Public HTTP contracts. Internal object keys and quota rows are never returned. */
|
|
2
18
|
export interface UploadInput {
|
|
3
19
|
collectionId: string;
|
|
@@ -25,6 +41,48 @@ export interface UploadInput {
|
|
|
25
41
|
* is the root. '/'-separated, with no leading, trailing or empty segment.
|
|
26
42
|
* A replacement keeps the file's path. */
|
|
27
43
|
path?: string;
|
|
44
|
+
/** Who besides `files.files:read-any` may see this file (issue #71).
|
|
45
|
+
* Omitted defaults to `[]` -- the uploader only, for a person and an app
|
|
46
|
+
* key alike. Only applies to a brand new file -- a `replace` upload keeps
|
|
47
|
+
* the target file's existing audience, and must not set this; change it
|
|
48
|
+
* afterward with `setFileAudience`. */
|
|
49
|
+
audience?: FileAudience;
|
|
50
|
+
/** Whether this file can ever back a public share link (issue #71).
|
|
51
|
+
* Omitted defaults to `false` for a signed-in person, `true` for any
|
|
52
|
+
* other principal class (an app key), so existing app-key integrations
|
|
53
|
+
* keep minting links unchanged. Only applies to a brand new file -- a
|
|
54
|
+
* `replace` upload keeps the target file's existing setting, and must not
|
|
55
|
+
* set this; change it afterward with `setFileAudience`. */
|
|
56
|
+
shareable?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** `copyFile`'s input (task F): copy an existing file's bytes to a brand new
|
|
59
|
+
* file, in the same collection (the default) or another one in your own
|
|
60
|
+
* organisation, with an optional new path and/or name. The result is always
|
|
61
|
+
* a new file id -- never a replace -- and copying a file in another
|
|
62
|
+
* organisation is simply impossible: the source, if not visible to you,
|
|
63
|
+
* 404s the same way any other cross-organisation file read does. Variants
|
|
64
|
+
* and share links are never copied. */
|
|
65
|
+
export interface CopyFileInput {
|
|
66
|
+
/** Defaults to the source file's own collection. */
|
|
67
|
+
collectionId?: string;
|
|
68
|
+
/** Defaults to the source file's own path. */
|
|
69
|
+
path?: string;
|
|
70
|
+
/** Defaults to the source file's own name. */
|
|
71
|
+
name?: string;
|
|
72
|
+
}
|
|
73
|
+
/** `moveFile`'s input (task D): change `path` and/or `name` of an existing
|
|
74
|
+
* file within its own collection, without re-uploading it. At least one of
|
|
75
|
+
* `path`/`name` must be given. `version` is the file's current version
|
|
76
|
+
* (optimistic concurrency, same as a version-pinned read): a stale value
|
|
77
|
+
* throws `ApiError` with `status: 409`, the same conflict a version-pinned
|
|
78
|
+
* `getFile`/`downloadFile` already gives. Moving onto a `path`+`name` another
|
|
79
|
+
* file in the collection already occupies throws the same `409`. */
|
|
80
|
+
export interface MoveFileInput {
|
|
81
|
+
/** The new folder inside the collection; omitted keeps the current one. */
|
|
82
|
+
path?: string;
|
|
83
|
+
/** The new file name; omitted keeps the current one. */
|
|
84
|
+
name?: string;
|
|
85
|
+
version: number;
|
|
28
86
|
}
|
|
29
87
|
export interface StoredFile {
|
|
30
88
|
id: string;
|
|
@@ -51,6 +109,41 @@ export interface StoredFile {
|
|
|
51
109
|
* still on record. `null` for a file at version 0 (never confirmed), or
|
|
52
110
|
* when the confirming upload can no longer be found. */
|
|
53
111
|
principalRef: string | null;
|
|
112
|
+
/** Who besides `files.files:read-any` may see this file (issue #71).
|
|
113
|
+
* Change it with `setFileAudience`; the owner is always allowed. */
|
|
114
|
+
audience: FileAudience;
|
|
115
|
+
/** Whether this file can ever back a public share link (issue #71).
|
|
116
|
+
* Change it with `setFileAudience`; the owner is always allowed. */
|
|
117
|
+
shareable: boolean;
|
|
118
|
+
}
|
|
119
|
+
/** A page from `listMyFiles`/`listSharedFiles` (issue #72): newest file
|
|
120
|
+
* first (`created_at desc`, `id` breaking a tie). `next`, when not `null`,
|
|
121
|
+
* is an opaque cursor -- pass it back as `after` unchanged for the next
|
|
122
|
+
* page; `null` means this was the last page. */
|
|
123
|
+
export interface FilesPage {
|
|
124
|
+
files: StoredFile[];
|
|
125
|
+
next: string | null;
|
|
126
|
+
}
|
|
127
|
+
/** `listSharedFiles`'s own result row (issue #72): a `StoredFile` the
|
|
128
|
+
* caller did not upload but may read through its `audience` (issue #71),
|
|
129
|
+
* plus who owns it -- `listMyFiles`/`getFile` never need this, since the
|
|
130
|
+
* caller already knows a file is theirs. */
|
|
131
|
+
export interface SharedFile extends StoredFile {
|
|
132
|
+
owner: {
|
|
133
|
+
id: string;
|
|
134
|
+
class: 'human' | 'api_key' | 'agent' | 'service';
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export interface SharedFilesPage {
|
|
138
|
+
files: SharedFile[];
|
|
139
|
+
next: string | null;
|
|
140
|
+
}
|
|
141
|
+
/** `setFileAudience`'s input: `audience` replaces the whole list (not a
|
|
142
|
+
* merge); `shareable`, omitted, leaves the file's current setting
|
|
143
|
+
* untouched. */
|
|
144
|
+
export interface SetFileAudienceInput {
|
|
145
|
+
audience: FileAudience;
|
|
146
|
+
shareable?: boolean;
|
|
54
147
|
}
|
|
55
148
|
/** Lazily generated image variants (task gap-32), served through
|
|
56
149
|
* `getFileVariant`. A fixed allowlist -- not an arbitrary size -- so a
|
|
@@ -149,6 +242,11 @@ export interface OrganisationSummary {
|
|
|
149
242
|
* organisations: a host another organisation already owns is refused with
|
|
150
243
|
* 409 `conflict`. */
|
|
151
244
|
shareHost: string | null;
|
|
245
|
+
/** Set by `deleteOrganisation()` (task O, issue #67); `null` for a live
|
|
246
|
+
* organisation. `restoreOrganisation()` clears it back to `null` within
|
|
247
|
+
* the 7-day grace period; after that, the organisation and everything it
|
|
248
|
+
* stored is purged for good and every call answers `not_found`. */
|
|
249
|
+
deletedAt: string | null;
|
|
152
250
|
}
|
|
153
251
|
export interface ProvisionOrganisationInput {
|
|
154
252
|
organisationId: string;
|
|
@@ -156,10 +254,19 @@ export interface ProvisionOrganisationInput {
|
|
|
156
254
|
ceilingMicros: string | null;
|
|
157
255
|
/** Omitted or `null`: provisioned with no share domain. */
|
|
158
256
|
shareHost?: string | null;
|
|
257
|
+
/** With `owner`: onboards Agora for the new organisation. */
|
|
258
|
+
name?: string;
|
|
259
|
+
/** Subject id of the organisation's first, permanent owner. */
|
|
260
|
+
owner?: string;
|
|
159
261
|
}
|
|
160
262
|
export interface ProvisionOrganisationResult extends OrganisationSummary {
|
|
161
263
|
/** False when already provisioned: existing ceilings and share host were retained. */
|
|
162
264
|
created: boolean;
|
|
265
|
+
/** Agora's service key, returned once on a 201 that passed `name` and `owner`. */
|
|
266
|
+
agoraKey?: {
|
|
267
|
+
id: string;
|
|
268
|
+
secret: string;
|
|
269
|
+
};
|
|
163
270
|
}
|
|
164
271
|
export interface UpdateOrganisationCeilingInput {
|
|
165
272
|
ceilingBytes?: string | null;
|
|
@@ -210,9 +317,12 @@ export interface UsageDay {
|
|
|
210
317
|
rateMicrosPerGbDay: number;
|
|
211
318
|
}
|
|
212
319
|
/** Outbound event delivery (task gap-36): at most one endpoint per
|
|
213
|
-
* organisation, POSTed a signed `files.upload_confirmed
|
|
214
|
-
* `files.quota_near_ceiling`
|
|
215
|
-
*
|
|
320
|
+
* organisation, POSTed a signed `files.upload_confirmed`,
|
|
321
|
+
* `files.quota_near_ceiling`, `files.file_deleted` or
|
|
322
|
+
* `files.variant_created` event instead of the caller polling. Delivery
|
|
323
|
+
* is best-effort with no retry queue -- a receiver must be idempotent. See
|
|
324
|
+
* packages/sdk/README.md's Webhooks section for each event's payload
|
|
325
|
+
* shape. */
|
|
216
326
|
export interface WebhookConfig {
|
|
217
327
|
url: string | null;
|
|
218
328
|
}
|
|
@@ -224,6 +334,41 @@ export interface SetWebhookResult extends WebhookConfig {
|
|
|
224
334
|
* header with it. */
|
|
225
335
|
secret: string | null;
|
|
226
336
|
}
|
|
337
|
+
/** Task M (campaign 2026-09-25-agora-files): a membership API for products.
|
|
338
|
+
* The product that owns its users writes memberships in its own files
|
|
339
|
+
* organisation -- files never auto-admits a person by token alone. `subject`
|
|
340
|
+
* is always a person's token subject (a `human` principal). */
|
|
341
|
+
export interface Member {
|
|
342
|
+
subject: string;
|
|
343
|
+
/** A role key, e.g. `files_member` (the built-in role, files read/upload/download/share on their own files) or a custom role. */
|
|
344
|
+
role: string;
|
|
345
|
+
source: string;
|
|
346
|
+
grantedBy: string | null;
|
|
347
|
+
createdAt: string;
|
|
348
|
+
}
|
|
349
|
+
export interface AddMemberInput {
|
|
350
|
+
subject: string;
|
|
351
|
+
/** Ignored if `subject` is already a member (`addMember` is idempotent);
|
|
352
|
+
* use `setMemberRole` to change an existing member's role. */
|
|
353
|
+
role: string;
|
|
354
|
+
}
|
|
355
|
+
/** Task G (campaign 2026-09-25-agora-files): app-owned groups. An app (chat,
|
|
356
|
+
* say) creates `group:chat:room/!abc` and keeps its membership in sync as
|
|
357
|
+
* its own room changes; a file's `audience` (`FileAudience` above) can then
|
|
358
|
+
* name that group ref to admit its members to a read. Every write here is
|
|
359
|
+
* refused unless the calling key's own credential names the group ref's own
|
|
360
|
+
* `<app>` segment (set at `keys:issue --app`, apps/host's README) -- a
|
|
361
|
+
* human, or a key naming a different app, gets a 403. This ownership rule
|
|
362
|
+
* is provisional and may change. */
|
|
363
|
+
export type PrincipalClass = 'human' | 'api_key' | 'agent' | 'service';
|
|
364
|
+
export interface GroupMember {
|
|
365
|
+
class: PrincipalClass;
|
|
366
|
+
id: string;
|
|
367
|
+
}
|
|
368
|
+
export interface Group {
|
|
369
|
+
groupRef: string;
|
|
370
|
+
createdAt: string;
|
|
371
|
+
}
|
|
227
372
|
/** Operator audit filters. Omitted dates select the most recent 92 days. */
|
|
228
373
|
export interface AuditQuery {
|
|
229
374
|
organisation?: string;
|