@wtfalch/files 0.7.0 → 0.8.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 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`. The R2 backend supports this; the
42
- local-disk backend does not yet.
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; moving a file is not
59
- supported yet.
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
@@ -154,3 +361,97 @@ if (!result.created) {
154
361
  `allowInsecureHttp` should come from trusted deployment configuration, never a
155
362
  request parameter. Keep application permission checks inside the supplied fetch
156
363
  and retain the UI's error mapping when replacing a custom client with the SDK.
364
+
365
+ ### Deleting an organisation
366
+
367
+ `deleteOrganisation(organisationId)` deletes an organisation with a 7-day
368
+ grace period: it suspends the organisation immediately (every tenant write
369
+ and public share link is refused straight away, the same as a ceiling-update
370
+ suspension) and marks it deleted. `restoreOrganisation(organisationId)` undoes
371
+ that within the 7 days, clearing the suspension the delete itself set back to
372
+ whatever the organisation's suspension was before -- an operator suspension
373
+ set on purpose earlier is never silently cleared by a restore. Both need
374
+ `files.organisations:delete`, a permission of its own that
375
+ `files.organisations:provision` does not include.
376
+
377
+ ```ts
378
+ const deleted = await management.deleteOrganisation(organisationId);
379
+ // deleted.deletedAt is the ISO timestamp the grace period counts from.
380
+ await management.restoreOrganisation(organisationId); // any time in the next 7 days
381
+ ```
382
+
383
+ Deleting is idempotent: deleting an organisation that is already deleted
384
+ succeeds again, unchanged, rather than throwing, so a caller that never saw a
385
+ first call's response (a dropped connection, a timeout) can always retry.
386
+ Deleting one that was never provisioned throws `ApiError` with `status: 404`.
387
+ Once the grace period passes, a daily job purges the organisation for good --
388
+ every row and every stored object -- and `restoreOrganisation` then throws
389
+ `ApiError` with `status: 404` too, the same as for an organisation that was
390
+ never deleted; the two are not distinguishable from the client, by design.
391
+
392
+ ### Membership
393
+
394
+ The product that owns its users (Agora, lokessmie) writes memberships in its
395
+ own files organisation -- files never auto-admits a signed-in person by token
396
+ alone. `subject` is always the person's token subject (a `human` principal);
397
+ `role` is a role key, either the built-in `files_member` role (reads, uploads,
398
+ downloads and shares their own files -- the role a product assigns to an
399
+ ordinary person) or a custom role the organisation defined. Every call needs
400
+ `files.members:read` (list) or `files.members:update` (add/change role/remove),
401
+ held by the calling key for its own organisation only -- `files.members:update`
402
+ is sensitive: a key can never grant or take away a role wider than its own
403
+ authority already covers, so a files-scoped product key can grant
404
+ `files_member` but not, say, `admin` (which also needs billing/tenant
405
+ permissions no files-only key holds).
406
+
407
+ ```ts
408
+ const { members } = await management.listMembers();
409
+ const added = await management.addMember({ subject: personId, role: 'files_member' });
410
+ await management.setMemberRole(personId, 'c_read_only'); // a custom role the org defined
411
+ await management.removeMember(personId);
412
+ ```
413
+
414
+ `addMember` is idempotent: naming a subject who is already a member returns
415
+ their current row unchanged (`role` in the input is ignored in that case) --
416
+ use `setMemberRole` to change an existing member's role. `removeMember` is
417
+ idempotent too: removing a subject who is not, or no longer, a member still
418
+ succeeds. Both, like `setMemberRole`, throw `ApiError` with `status: 422` if
419
+ the calling key cannot delegate the role involved.
420
+
421
+ ## Webhooks
422
+
423
+ `management.setWebhook(url)` sets the single outbound endpoint for the
424
+ caller's organisation; `management.setWebhook(null)` clears it and stops
425
+ delivery. `url` must be `https://`, under 2048 characters, with no embedded
426
+ credentials, and not point at an obviously local or private address (this is
427
+ a syntactic check only, not DNS-resolution-time SSRF hardening). A successful
428
+ set returns a fresh `secret`, generated on every call -- this is also how you
429
+ rotate it. `secret` is returned only from `setWebhook()`; `management.getWebhook()`
430
+ returns just the current `url`. Both calls require the same permission as the
431
+ tenant's quota and budget calls.
432
+
433
+ Four event types are delivered, each as a JSON POST body of the shape
434
+ `{ type, occurredAt, organisationId, data }`:
435
+
436
+ - `files.upload_confirmed`, sent after an upload confirms, with `data`:
437
+ `fileId`, `collectionId`, `bytes`, `contentType`, `checksum`, `sha256` and
438
+ `principalRef` (the confirming principal).
439
+ - `files.quota_near_ceiling`, sent when a fresh read of the organisation's
440
+ committed bytes (used plus reserved) reaches 90% of its effective cap, with
441
+ `data`: `usedBytes`, `reservedBytes` and `ceilingBytes`.
442
+ - `files.file_deleted`, sent after `deleteFile()` commits, with `data`:
443
+ `fileId`, `collectionId` and `version` (the version at deletion).
444
+ - `files.variant_created`, sent when `getFileVariant()` generates and stores a
445
+ new resized copy (not on a cache hit), with `data`: `fileId`, `version`,
446
+ `width`, `format` and `bytes`.
447
+
448
+ Every request carries an `x-files-webhook-signature: sha256=<hex>` header,
449
+ the HMAC-SHA256 of the exact raw request body using the `secret` from
450
+ `setWebhook()`. Verify it by recomputing that HMAC over the raw body you
451
+ received and comparing it to the header before trusting the payload.
452
+
453
+ Delivery is best effort: it never retries, times out after 5 seconds, and a
454
+ failed or unreachable endpoint never fails the upload or quota check that
455
+ triggered it. There is no persistent retry queue, so a receiver must treat
456
+ delivery as at-most-once (or not-at-all) and reconcile through the regular
457
+ 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
- /** `path` lists only the files directly in that folder. */
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
- /** `path` lists only the files directly in that folder. */
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;
@@ -210,9 +308,12 @@ export interface UsageDay {
210
308
  rateMicrosPerGbDay: number;
211
309
  }
212
310
  /** Outbound event delivery (task gap-36): at most one endpoint per
213
- * organisation, POSTed a signed `files.upload_confirmed` or
214
- * `files.quota_near_ceiling` event instead of the caller polling. Delivery
215
- * is best-effort with no retry queue -- a receiver must be idempotent. */
311
+ * organisation, POSTed a signed `files.upload_confirmed`,
312
+ * `files.quota_near_ceiling`, `files.file_deleted` or
313
+ * `files.variant_created` event instead of the caller polling. Delivery
314
+ * is best-effort with no retry queue -- a receiver must be idempotent. See
315
+ * packages/sdk/README.md's Webhooks section for each event's payload
316
+ * shape. */
216
317
  export interface WebhookConfig {
217
318
  url: string | null;
218
319
  }
@@ -224,6 +325,41 @@ export interface SetWebhookResult extends WebhookConfig {
224
325
  * header with it. */
225
326
  secret: string | null;
226
327
  }
328
+ /** Task M (campaign 2026-09-25-agora-files): a membership API for products.
329
+ * The product that owns its users writes memberships in its own files
330
+ * organisation -- files never auto-admits a person by token alone. `subject`
331
+ * is always a person's token subject (a `human` principal). */
332
+ export interface Member {
333
+ subject: string;
334
+ /** A role key, e.g. `files_member` (the built-in role, files read/upload/download/share on their own files) or a custom role. */
335
+ role: string;
336
+ source: string;
337
+ grantedBy: string | null;
338
+ createdAt: string;
339
+ }
340
+ export interface AddMemberInput {
341
+ subject: string;
342
+ /** Ignored if `subject` is already a member (`addMember` is idempotent);
343
+ * use `setMemberRole` to change an existing member's role. */
344
+ role: string;
345
+ }
346
+ /** Task G (campaign 2026-09-25-agora-files): app-owned groups. An app (chat,
347
+ * say) creates `group:chat:room/!abc` and keeps its membership in sync as
348
+ * its own room changes; a file's `audience` (`FileAudience` above) can then
349
+ * name that group ref to admit its members to a read. Every write here is
350
+ * refused unless the calling key's own credential names the group ref's own
351
+ * `<app>` segment (set at `keys:issue --app`, apps/host's README) -- a
352
+ * human, or a key naming a different app, gets a 403. This ownership rule
353
+ * is provisional and may change. */
354
+ export type PrincipalClass = 'human' | 'api_key' | 'agent' | 'service';
355
+ export interface GroupMember {
356
+ class: PrincipalClass;
357
+ id: string;
358
+ }
359
+ export interface Group {
360
+ groupRef: string;
361
+ createdAt: string;
362
+ }
227
363
  /** Operator audit filters. Omitted dates select the most recent 92 days. */
228
364
  export interface AuditQuery {
229
365
  organisation?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/files",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Public client SDK and API types for the Files service.",