@wtfalch/files 0.10.0 → 0.11.1

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
@@ -6,7 +6,7 @@ package dependencies.
6
6
 
7
7
  ## Status
8
8
 
9
- Published: yes, v0.10.0, 2026-10-03.
9
+ Published: yes, v0.11.0, 2026-10-03.
10
10
 
11
11
  ```sh
12
12
  npm install @wtfalch/files
@@ -189,6 +189,11 @@ app key), so lokessmie's and offline.tf's existing app-key integrations keep
189
189
  minting links unchanged. Change it afterward with `setFileAudience`'s own
190
190
  `shareable` field.
191
191
 
192
+ A link's `expiresAt` is `null` for a link that never stops working, or a time
193
+ at most 365 days ahead (a few minutes more are allowed for a caller's clock
194
+ running ahead). That is the range of an app's `FILES_SHARE_LINK_DAYS`
195
+ (1 to 365, or `never`): send `now + days`, or `null` for `never`.
196
+
192
197
  ### Seeing everything: `files.files:read-any`
193
198
 
194
199
  A sensitive permission: read any file in the organisation, whatever its
@@ -252,13 +257,16 @@ is a separate rollout; installing the SDK does not migrate accounts or files.
252
257
 
253
258
  ## Calling from a browser through a same-origin proxy
254
259
 
255
- The files host itself has no CORS headers and no public domain (task #70):
256
260
  `credential()` above must never be a privileged service credential in
257
- browser code, and a browser cannot reach the host directly either way. A
258
- browser app such as Agora instead runs its own same-origin proxy -- a route
259
- on the app's own origin that forwards `Authorization` and
260
- `X-Organisation-Id` to the files host on its private network, stripping any
261
- cookies -- and points the SDK at itself:
261
+ browser code. The host now answers a CORS preflight and allows a direct
262
+ browser call for an origin its operator has allow-listed
263
+ (`FILES_ALLOWED_ORIGINS` -- see `@wtfalch/files-host`'s README, "Cross-origin
264
+ browser calls"); point `createFilesClient` at the host's own URL in that
265
+ case. For any other origin, or before it is allow-listed, the host has no
266
+ public domain for it to reach anyway, so a browser app such as Agora instead
267
+ runs its own same-origin proxy -- a route on the app's own origin that
268
+ forwards `Authorization` and `X-Organisation-Id` to the files host on its
269
+ private network, stripping any cookies -- and points the SDK at itself:
262
270
 
263
271
  ```ts
264
272
  const files = createFilesClient({
@@ -398,6 +406,84 @@ every row and every stored object -- and `restoreOrganisation` then throws
398
406
  `ApiError` with `status: 404` too, the same as for an organisation that was
399
407
  never deleted; the two are not distinguishable from the client, by design.
400
408
 
409
+ ### Quotas and budgets: operator ceiling vs. organisation limit
410
+
411
+ Two different levels cap an organisation's bytes and spend, and only one of
412
+ them is operator-settable. `updateOrganisationCeiling` sets the operator
413
+ **ceiling** (`ceilingBytes`/`ceilingMicros`) -- the platform-wide cap a
414
+ support/operator credential may set for any organisation, passed by
415
+ `organisationId` like the rest of the organisation-management calls above.
416
+ `getQuota`/`setQuota`/`getBudget`/`setBudget` work on the organisation's own
417
+ **limit** (`limitBytes`/`limitMicros`) instead, and are tenant self-service
418
+ only: they take no `organisationId` because the target is always read off
419
+ the signed request's own identity, never a parameter a caller can point
420
+ elsewhere.
421
+
422
+ ```ts
423
+ // Operator-settable, any organisation, by id:
424
+ await management.updateOrganisationCeiling(organisationId, { ceilingBytes: '5000000000' });
425
+
426
+ // Tenant self-service, the caller's own organisation only:
427
+ const tenantManagement = createFilesManagementClient({ url, fetch: tenantFetch });
428
+ await tenantManagement.setQuota('1000000000');
429
+ await tenantManagement.setBudget('250000000');
430
+ ```
431
+
432
+ There is no operator-callable way to set an organisation's own limit on its
433
+ behalf. This was decided deliberately (issue #116): a freshly provisioned
434
+ organisation's limit starts `null` (uncapped at that level), so the operator
435
+ ceiling alone governs it until the organisation sets a tighter limit for
436
+ itself, from its own tenant application (for example Boule). An application
437
+ provisioning an organisation on an operator's behalf (Archon's foundry step,
438
+ for one) should set the ceiling and stop there -- not attempt to also seed
439
+ the organisation's own limit, which no credential it holds can do.
440
+
441
+ ### One app's files (issue #161)
442
+
443
+ A company's admin app (Boule) reads one app's files with a key of the
444
+ company's own organisation: `appFilesTotals`, `appFilesFolders`,
445
+ `appFilesSpace`, `appFiles` and `appFilesLinks`, and `stopSharing` to revoke
446
+ a link. Every call names the app's collections by name and reads only those;
447
+ the organisation is always the key's own. Files knows no apps and no copies:
448
+ a workspace is its own organisation, read with its own key. The key needs
449
+ `files.usage:read` (totals, folders, space), `files.files:read` (the files
450
+ list), `files.shares:read` (the links) and `files.files:share` (Stop sharing),
451
+ each across the organisation.
452
+
453
+ ```ts
454
+ const app = { collections: ['INVOICES', 'PHOTOS'] };
455
+ const totals = await tenantManagement.appFilesTotals(app);
456
+ const { files, more } = await tenantManagement.appFiles({ ...app, search: 'march', limit: 50 });
457
+ await tenantManagement.stopSharing({ ...app, link: linkId });
458
+ ```
459
+
460
+ ### An app's space alert (issue #164)
461
+
462
+ Foundry sets each app's `spaceAlertPercent` with the operator's management
463
+ key (`files.organisations:provision`, the permission that ensures the app's
464
+ collections): `setSpaceAlert(organisationId, app, { percent, name,
465
+ collections, recipients })` (PUT
466
+ `/v1/manage/organisations/:organisationId/apps/:app/space-alert`),
467
+ `getSpaceAlert` (GET) and `removeSpaceAlert` (DELETE, a no-op when there is
468
+ none). `app` is the entry key, `percent` is 1 to 100 and defaults to 80,
469
+ `collections` are the app's collection names (1 to 50) and `recipients` the
470
+ owners' addresses (at most 20); files keeps no address for anyone, so
471
+ foundry sends the setting again when the owners change. Every day, after the
472
+ meter, files mails each recipient once when the bytes of the app's live files
473
+ reach `percent` of the organisation's plan space (the tighter of its limit and
474
+ the operator ceiling, `planBytes` in the totals), and again only after they
475
+ drop below it and reach it again. A send that fails is tried on the next day's
476
+ check; nobody is mailed twice for one crossing.
477
+
478
+ ```ts
479
+ await management.setSpaceAlert(organisationId, 'shop', {
480
+ percent: 80,
481
+ name: 'Shop',
482
+ collections: ['INVOICES', 'PHOTOS'],
483
+ recipients: ['owner@acme.example'],
484
+ });
485
+ ```
486
+
401
487
  ### Membership
402
488
 
403
489
  The product that owns its users (Agora, lokessmie) writes memberships in its
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ClientOptions, type ManagementClientOptions } from './transport.js';
2
- import type { AddMemberInput, AuditPage, AuditQuery, BudgetSummary, CopyFileInput, CreatedShare, FilesPage, FilesRate, Group, GroupMember, IssueKeyInput, IssueOrganisationKeyInput, IssuedOrganisationKey, Member, MoveFileInput, OrganisationShare, OrganisationSummary, ProvisionOrganisationInput, ProvisionOrganisationResult, QuotaSummary, RevokedOrganisationKey, SetFileAudienceInput, SetRateInput, SetWebhookResult, Share, SharedFilesPage, StoredFile, UpdateOrganisationCeilingInput, UploadInput, UploadReservation, UploadedPart, UsageDay, VariantFormat, VariantWidth, WebhookConfig } from './types.js';
2
+ import type { AddMemberInput, AppFilesEntry, AppFilesFolder, AppFilesLink, AppFilesListQuery, AppFilesQuery, AppFilesSpace, AppFilesTotals, AuditPage, AuditQuery, BudgetSummary, CopyFileInput, CreatedShare, FilesPage, FilesRate, Group, GroupMember, IssueKeyInput, IssueOrganisationKeyInput, IssuedOrganisationKey, Member, MoveFileInput, OrganisationShare, OrganisationSummary, ProvisionOrganisationInput, ProvisionOrganisationResult, QuotaSummary, RevokedOrganisationKey, SetFileAudienceInput, SetRateInput, SetSpaceAlertInput, SetWebhookResult, Share, SharedFilesPage, SpaceAlert, 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<{
@@ -130,6 +130,18 @@ export declare function createFilesManagementClient(options: ManagementClientOpt
130
130
  id: string;
131
131
  name: string;
132
132
  }>;
133
+ setSpaceAlert: (organisationId: string, app: string, input: SetSpaceAlertInput) => Promise<{
134
+ created: boolean;
135
+ app: string;
136
+ name: string;
137
+ percent: number;
138
+ collections: string[];
139
+ recipients: string[];
140
+ above: boolean;
141
+ updatedAt: string;
142
+ }>;
143
+ getSpaceAlert: (organisationId: string, app: string) => Promise<SpaceAlert>;
144
+ removeSpaceAlert: (organisationId: string, app: string) => Promise<void>;
133
145
  updateOrganisationCeiling: (organisationId: string, input: UpdateOrganisationCeilingInput) => Promise<OrganisationSummary>;
134
146
  deleteOrganisation: (organisationId: string) => Promise<OrganisationSummary>;
135
147
  restoreOrganisation: (organisationId: string) => Promise<OrganisationSummary>;
@@ -145,6 +157,24 @@ export declare function createFilesManagementClient(options: ManagementClientOpt
145
157
  listOrganisationShares: () => Promise<{
146
158
  shares: OrganisationShare[];
147
159
  }>;
160
+ appFilesTotals: (query: AppFilesQuery) => Promise<AppFilesTotals>;
161
+ appFilesFolders: (query: AppFilesQuery) => Promise<{
162
+ folders: AppFilesFolder[];
163
+ }>;
164
+ appFilesSpace: (query: AppFilesQuery) => Promise<AppFilesSpace>;
165
+ appFiles: (query: AppFilesListQuery) => Promise<{
166
+ files: AppFilesEntry[];
167
+ more: boolean;
168
+ }>;
169
+ appFilesLinks: (query: AppFilesQuery & {
170
+ limit?: number;
171
+ }) => Promise<{
172
+ links: AppFilesLink[];
173
+ total: number;
174
+ }>;
175
+ stopSharing: (query: AppFilesQuery & {
176
+ link: string;
177
+ }) => Promise<void>;
148
178
  getWebhook: () => Promise<WebhookConfig>;
149
179
  setWebhook: (url: string | null) => Promise<SetWebhookResult>;
150
180
  issueKey: (input: IssueKeyInput) => Promise<IssuedOrganisationKey>;
package/dist/client.js CHANGED
@@ -156,6 +156,15 @@ export function createFilesManagementClient(options) {
156
156
  const { data, status } = await callWithStatus('POST', `/v1/manage/organisations/${encodeURIComponent(organisationId)}/collections`, { name });
157
157
  return { ...data, created: status === 201 };
158
158
  },
159
+ // An app's space alert (issue #164). `created` is true only on the call
160
+ // that made it. `getSpaceAlert` throws `ApiError` 404 when there is none;
161
+ // `removeSpaceAlert` is a no-op when there is none.
162
+ setSpaceAlert: async (organisationId, app, input) => {
163
+ const { data, status } = await callWithStatus('PUT', spaceAlertPath(organisationId, app), input);
164
+ return { ...data, created: status === 201 };
165
+ },
166
+ getSpaceAlert: (organisationId, app) => call('GET', spaceAlertPath(organisationId, app)),
167
+ removeSpaceAlert: (organisationId, app) => call('DELETE', spaceAlertPath(organisationId, app)),
159
168
  updateOrganisationCeiling: (organisationId, input) => call('PATCH', `/v1/manage/organisations/${encodeURIComponent(organisationId)}`, input),
160
169
  // Delete an organisation with a 7-day grace period (task O, issue #67).
161
170
  // Idempotent: deleting an already-deleted organisation succeeds again
@@ -170,6 +179,10 @@ export function createFilesManagementClient(options) {
170
179
  restoreOrganisation: (organisationId) => call('POST', `/v1/manage/organisations/${encodeURIComponent(organisationId)}/restore`),
171
180
  // Tenant routes (task S4b): the caller's own organisation, from the
172
181
  // signed request's claim -- there is no organisation id to pass here.
182
+ // Self-service only, by design (issue #116): there is no
183
+ // operator-callable variant that targets another organisation's own
184
+ // limit. An operator sets only the ceiling, updateOrganisationCeiling
185
+ // above. See the SDK README's "Quotas and budgets" section.
173
186
  getQuota: () => call('GET', '/v1/manage/quota'),
174
187
  setQuota: (limitBytes) => call('PUT', '/v1/manage/quota', { limitBytes }),
175
188
  setKeyQuota: (keyId, limitBytes) => call('PUT', `/v1/manage/keys/${encodeURIComponent(keyId)}/quota`, {
@@ -184,6 +197,16 @@ export function createFilesManagementClient(options) {
184
197
  // Public share links (task S6): every active link in the caller's
185
198
  // organisation, with the file's name -- never the token or URL.
186
199
  listOrganisationShares: () => call('GET', '/v1/manage/shares'),
200
+ // One app's files (issue #161): only the named collections of the
201
+ // caller's own organisation.
202
+ appFilesTotals: (query) => call('GET', `/v1/manage/report/totals?${reportParams(query)}`),
203
+ appFilesFolders: (query) => call('GET', `/v1/manage/report/folders?${reportParams(query)}`),
204
+ appFilesSpace: (query) => call('GET', `/v1/manage/report/space?${reportParams(query)}`),
205
+ appFiles: (query) => call('GET', `/v1/manage/report/files?${reportParams(query)}`),
206
+ appFilesLinks: (query) => call('GET', `/v1/manage/report/links?${reportParams(query)}`),
207
+ // Stop sharing: the link stops opening at once. A link outside the named
208
+ // collections is not found.
209
+ stopSharing: (query) => call('DELETE', `/v1/manage/report/links/${encodeURIComponent(query.link)}?${reportParams({ collections: query.collections })}`),
187
210
  // Outbound event delivery (task gap-36). setWebhook(null) disables it.
188
211
  getWebhook: () => call('GET', '/v1/manage/webhook'),
189
212
  setWebhook: (url) => call('PUT', '/v1/manage/webhook', { url }),
@@ -209,3 +232,17 @@ export function createFilesManagementClient(options) {
209
232
  removeMember: (subject) => call('DELETE', `/v1/manage/members/${encodeURIComponent(subject)}`),
210
233
  };
211
234
  }
235
+ /** `collection=A&collection=B&...` plus the list query's own fields. */
236
+ function reportParams(query) {
237
+ const params = new URLSearchParams();
238
+ for (const name of query.collections)
239
+ params.append('collection', name);
240
+ if (query.folder)
241
+ params.set('folder', query.folder);
242
+ if (query.search)
243
+ params.set('search', query.search);
244
+ if (query.limit !== undefined)
245
+ params.set('limit', String(query.limit));
246
+ return params;
247
+ }
248
+ const spaceAlertPath = (organisationId, app) => `/v1/manage/organisations/${encodeURIComponent(organisationId)}/apps/${encodeURIComponent(app)}/space-alert`;
package/dist/types.d.ts CHANGED
@@ -442,3 +442,103 @@ export interface AuditPage {
442
442
  events: AuditEvent[];
443
443
  next: string | null;
444
444
  }
445
+ /** One app's files, read by the company's own admin app (issue #161): every
446
+ * call names the app's collections by name (the `collections` of foundry's
447
+ * `files` module) and reads only those, inside the one organisation the key
448
+ * belongs to. Files has no notion of an app or of a copy (live or
449
+ * workspace): a copy is its own organisation, read with that organisation's
450
+ * key. Bytes and counts are JSON numbers; times are ISO, days `YYYY-MM-DD`
451
+ * in UTC. */
452
+ export interface AppFilesQuery {
453
+ /** The app's collections, by name. 1 to 50. */
454
+ collections: string[];
455
+ }
456
+ export interface AppFilesTotals {
457
+ /** Active files in the app's collections. */
458
+ files: number;
459
+ bytes: number;
460
+ /** The organisation's byte cap: the tighter of its own limit and the
461
+ * operator ceiling. Organisation-wide, not the app's alone. `null`: none set. */
462
+ planBytes: number | null;
463
+ /** Files added since the start of this UTC month (still active). */
464
+ addedThisMonth: number;
465
+ /** Confirmed uploads in the last 7 UTC days, today included. */
466
+ uploadedThisWeek: number;
467
+ /** Downloads and share-link opens in the last 7 UTC days, today included. */
468
+ openedThisWeek: number;
469
+ /** Share links that still open. */
470
+ openLinks: number;
471
+ }
472
+ export interface AppFilesFolder {
473
+ /** The collection's name. Files keeps no other label. */
474
+ name: string;
475
+ files: number;
476
+ bytes: number;
477
+ lastUploadAt: string | null;
478
+ /** Share links that still open. */
479
+ links: number;
480
+ }
481
+ export interface AppFilesSpace {
482
+ /** Oldest first, at most 90. A past day appears once the daily meter has
483
+ * recorded it; today is always last, read live. */
484
+ days: {
485
+ day: string;
486
+ bytes: number;
487
+ uploads: number;
488
+ }[];
489
+ /** The space used at the start of `month` (`YYYY-MM`, three months on) at
490
+ * the average daily growth of the recorded days; `null` with fewer than
491
+ * 7 recorded days or no growth. */
492
+ forecast: {
493
+ bytes: number;
494
+ month: string;
495
+ } | null;
496
+ }
497
+ export interface AppFilesEntry {
498
+ id: string;
499
+ name: string;
500
+ /** The collection's name. */
501
+ folder: string;
502
+ bytes: number;
503
+ /** When it was added. */
504
+ at: string;
505
+ }
506
+ export interface AppFilesListQuery extends AppFilesQuery {
507
+ /** One of `collections`, or every one of them when absent. */
508
+ folder?: string | null;
509
+ /** Matches the file's name or its folder, case-insensitively. */
510
+ search?: string;
511
+ /** 1 to 100; 50 when absent. */
512
+ limit?: number;
513
+ }
514
+ export interface AppFilesLink {
515
+ id: string;
516
+ /** The file's name. */
517
+ file: string;
518
+ opened: number;
519
+ expiresAt: string | null;
520
+ }
521
+ /** An app's space alert (issue #164): mail `recipients` once when the
522
+ * bytes of the app's `collections` reach `percent` of the organisation's
523
+ * plan space, and again only after they drop below it and reach it again.
524
+ * `app` is foundry's entry key. */
525
+ export interface SetSpaceAlertInput {
526
+ /** 1 to 100; absent is 80. */
527
+ percent?: number;
528
+ /** Shown in the mail; absent is the app key. */
529
+ name?: string;
530
+ /** The app's collections by name, 1 to 50. */
531
+ collections: string[];
532
+ /** The owners' addresses, at most 20. */
533
+ recipients: string[];
534
+ }
535
+ export interface SpaceAlert {
536
+ app: string;
537
+ name: string;
538
+ percent: number;
539
+ collections: string[];
540
+ recipients: string[];
541
+ /** True while the app is at or over its share, as of the last daily check. */
542
+ above: boolean;
543
+ updatedAt: string;
544
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/files",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Public client SDK and API types for the Files service.",