@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 +93 -7
- package/dist/client.d.ts +31 -1
- package/dist/client.js +37 -0
- package/dist/types.d.ts +100 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ package dependencies.
|
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
Published: yes, v0.
|
|
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
|
|
258
|
-
browser
|
|
259
|
-
|
|
260
|
-
`
|
|
261
|
-
|
|
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
|
+
}
|