slotlock 0.2.0 → 0.3.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/CHANGELOG.md +330 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +277 -53
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +12 -3
- package/agent-server.js +108 -78
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +355 -36
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +14 -1
- package/application-api.js +274 -12
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +6 -0
- package/dashboard.js +17 -1
- package/ddl.d.ts +45 -3
- package/ddl.js +538 -12
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +5 -5
- package/index.js +3 -3
- package/maintenance.d.ts +15 -5
- package/maintenance.js +59 -27
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.js +10 -0
- package/rules.js +6 -3
- package/self-host.d.ts +22 -8
- package/self-host.js +307 -34
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +334 -24
- package/store.js +2029 -290
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +213 -9
package/SPEC.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Slotlock agent-calendar specification 1.0
|
|
2
2
|
|
|
3
|
-
This document defines the interoperable scheduling semantics of Slotlock 0.
|
|
4
|
-
different transports or persistence layers, but must preserve these rules
|
|
3
|
+
This document defines the interoperable scheduling semantics of Slotlock 0.2 and later.
|
|
4
|
+
Implementations may use different transports or persistence layers, but must preserve these rules
|
|
5
|
+
when claiming compatibility. Which of them may change between releases is in VERSIONING.md.
|
|
5
6
|
|
|
6
7
|
## Resource model
|
|
7
8
|
|
|
@@ -15,8 +16,71 @@ rule assumes a particular industry.
|
|
|
15
16
|
- Availability rules use local wall time in the resource's IANA timezone.
|
|
16
17
|
- Every interval is half-open `[start, end)`. `end` must be strictly after `start`.
|
|
17
18
|
- Adjacent intervals do not conflict. Overlapping or touching availability windows may be merged.
|
|
18
|
-
-
|
|
19
|
-
|
|
19
|
+
- An event's time zone is an IANA name, kept in its canonical spelling (`europe/london` is stored
|
|
20
|
+
as `Europe/London`). A UTC offset such as `+05:00` is not a zone, and an event in one is refused.
|
|
21
|
+
- An event time in an hour the clocks skip moves forward by the gap, and one in an hour they repeat
|
|
22
|
+
is its first instant (RFC 5545 §3.3.5), except where "Daylight-saving time" below says otherwise.
|
|
23
|
+
- An availability window's edge in an hour the clocks skip stands where that hour ends, so a window
|
|
24
|
+
holds only the wall times it names that exist: in New York on 2027-03-14, [02:30, 03:30) is
|
|
25
|
+
03:00-03:30 EDT and [02:00, 02:30) is nothing. An edge in a repeated hour is its first instant.
|
|
26
|
+
|
|
27
|
+
## Daylight-saving time
|
|
28
|
+
|
|
29
|
+
An occurrence of a timed recurring event starts at its wall time read as above, and ends at the
|
|
30
|
+
later of two readings:
|
|
31
|
+
|
|
32
|
+
- its exact end: its start plus the series' exact length, DTEND − DTSTART as instants (RFC 5545
|
|
33
|
+
§3.8.5.3), or its DURATION, whose days and weeks are calendar days and whose hours, minutes and
|
|
34
|
+
seconds are exact (§3.3.6);
|
|
35
|
+
- its wall-clock end: DTEND's wall time as written, carried to the occurrence's date (one written
|
|
36
|
+
in an hour the clocks skip is carried as written), the later instant where the clocks repeat
|
|
37
|
+
that hour. A stored series keeps its first occurrence as instants, and reads this end from its
|
|
38
|
+
end instant.
|
|
39
|
+
|
|
40
|
+
It occupies `[start, later end)`, so whichever way people read an occurrence that the clocks
|
|
41
|
+
change inside, none of the time they hold is offered as free. A stored series' first occurrence
|
|
42
|
+
starts at the instant stored, which may be the second pass of a repeated hour. One-off events,
|
|
43
|
+
overrides (moved occurrences) and PERIOD RDATEs occupy exactly the times they give. Display and
|
|
44
|
+
export keep each event as written. Feeds, the trusted importer and the store read events this way
|
|
45
|
+
in every zone, including zones whose rules changed in the past and zones a feed defines only by
|
|
46
|
+
its VTIMEZONE.
|
|
47
|
+
|
|
48
|
+
A series' occurrences never overlap each other. Where one ends after the next starts (24 exact
|
|
49
|
+
hours from midnight run to 01:00 the night the clocks go forward, or an occurrence is longer than
|
|
50
|
+
the series' period), it ends where the next starts, and the next ends at the later of the two ends.
|
|
51
|
+
Together they hold the same time, and the store, which keeps each occurrence as a reservation
|
|
52
|
+
under one EXCLUDE constraint, can hold them. Two that start at the same instant are left as they
|
|
53
|
+
are. An all-day series occupies whole local days, from one local midnight to the next (a midnight
|
|
54
|
+
the clocks skip moves forward).
|
|
55
|
+
|
|
56
|
+
| Case | Example | Occupies |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| The clocks go forward inside an occurrence | London, daily 00:00-06:00, 2027-03-28 | six exact hours, 00:00Z-06:00Z |
|
|
59
|
+
| The clocks go back inside an occurrence | London, daily 00:00-06:00, 2026-10-25 | seven wall-clock hours, 2026-10-24 23:00Z-06:00Z |
|
|
60
|
+
| The clocks go back, southern hemisphere | Santiago, daily 22:00-04:00, night of 2027-04-03 | seven hours, 2027-04-04 01:00Z-08:00Z |
|
|
61
|
+
| The clocks go forward, southern hemisphere | Auckland, daily 00:00-06:00, 2027-09-26 | six hours, 2027-09-25 12:00Z-18:00Z |
|
|
62
|
+
| A day long, the clocks go forward | London, daily 00:00-00:00 from 2027-03-26 | on 2027-03-28, 00:00Z-23:00Z, where the next day starts |
|
|
63
|
+
| A week long, the clocks go forward | New York, weekly 00:00 for 168 hours from 2027-03-07 | the second week 2027-03-14 05:00Z to 03-21 04:00Z |
|
|
64
|
+
| An all-day series | Santiago, daily from 2027-09-03 | on 2027-09-05, 04:00Z (01:00, midnight being skipped) to 09-06 03:00Z |
|
|
65
|
+
| An occurrence starts in the skipped hour | Berlin, daily 02:30 for 75 minutes, 2027-03-28 | 75 exact minutes from 01:30Z (03:30 CEST) |
|
|
66
|
+
| A half-hour change | Lord Howe, daily 02:00-02:45, 2027-10-03 | 45 minutes from 2027-10-02 15:30Z |
|
|
67
|
+
| DTEND falls in the repeated hour | New York, daily 23:00-01:30 from 2026-10-31 | 2026-11-01 03:00Z to the later 01:30, 06:30Z |
|
|
68
|
+
| DTEND falls in the skipped hour | New York, daily 01:00-02:30 from 2027-03-14 | the first 06:00Z-07:30Z, as written; later days 90 minutes, to the written 02:30 |
|
|
69
|
+
| The first instance spans the change | Berlin, weekly 01:00-04:00 from 2027-03-28 | as written, 00:00Z-02:00Z; later weeks three hours |
|
|
70
|
+
| An occurrence starts in the repeated hour | London, daily 01:15-01:45 from 2026-10-20 | on 2026-10-25, its first 01:15 (00:15Z) to the later 01:45 (01:45Z) |
|
|
71
|
+
| A stored first occurrence in the repeated hour | London, stored from 2026-10-25 01:15Z | the second 01:15, as stored |
|
|
72
|
+
| A length the clocks reduce to nothing | DTSTART and DTEND both in one skipped hour, the end at its close | refused |
|
|
73
|
+
|
|
74
|
+
A series whose DTSTART the clocks skip is read from a feed (from the offset before the gap) but is
|
|
75
|
+
not stored: no instant reads back as its wall time. The same holds for an all-day series whose
|
|
76
|
+
first day's midnight the clocks skip (Santiago and Havana go forward at midnight). The trusted
|
|
77
|
+
importer stores an all-day series as a timed one from local midnight to local midnight, which the
|
|
78
|
+
union rule holds to its exact length too: where the clocks go forward on one of its days, or at the
|
|
79
|
+
midnight its first occurrence ends, it may also hold the hour after a midnight that the feed's days
|
|
80
|
+
do not, never less. The trusted importer does not store a timed series whose DTEND the clocks skip, or
|
|
81
|
+
whose DURATION counts days or weeks outside UTC: kept as its first occurrence's instants, the first
|
|
82
|
+
would hold an hour the feed does not on every later day, and the second on each day it spans that
|
|
83
|
+
the clocks go forward.
|
|
20
84
|
|
|
21
85
|
## Availability
|
|
22
86
|
|
|
@@ -24,7 +88,7 @@ Availability is the intersection of explicit bookable windows and the complement
|
|
|
24
88
|
No rule means no availability. Unsupported or invalid rules contribute no availability. A next-slot
|
|
25
89
|
answer must fit wholly inside the resulting free set for the requested positive duration.
|
|
26
90
|
|
|
27
|
-
|
|
91
|
+
Weekly availability supports RRULE cadence with explicit `BYDAY`, interval 1, optional absolute
|
|
28
92
|
`UNTIL`, a local start minute, and a positive duration. Rules requiring an authored recurrence anchor
|
|
29
93
|
such as `COUNT` or interval greater than 1 fail closed.
|
|
30
94
|
|
|
@@ -33,7 +97,9 @@ such as `COUNT` or interval greater than 1 fail closed.
|
|
|
33
97
|
- The persistence layer is the final conflict arbiter; an application pre-check is not authority.
|
|
34
98
|
- A live hold occupies the same range as a confirmed reservation until its database-clock expiry.
|
|
35
99
|
- Confirm and release operations are atomic and idempotent for their expected retry cases.
|
|
36
|
-
- A
|
|
100
|
+
- A hold lasts at most 7 days from its creation.
|
|
101
|
+
- A trailing buffer extends occupied time without changing the customer-visible end instant. It is
|
|
102
|
+
at most 30 days, and holds and reservations alike respect it.
|
|
37
103
|
- External commands use `(tenantRef, externalRef)` as stable identity and a strictly increasing
|
|
38
104
|
positive revision. A replay with the same revision and payload is idempotent; drift is rejected.
|
|
39
105
|
- Cancellation leaves a revisioned tombstone so delayed delivery cannot resurrect the identity.
|
|
@@ -58,6 +124,13 @@ the provider, map a connection to one resource, normalize status/transparency/re
|
|
|
58
124
|
durable cursor and coverage window, and reconcile deletions. iCalendar parsing must remain bounded by
|
|
59
125
|
input size, components, exceptions, occurrences, work, and expansion horizon.
|
|
60
126
|
|
|
127
|
+
A feed's overrides occupy what the provider shows: one without DTEND or DURATION lasts its series'
|
|
128
|
+
length, and one whose RECURRENCE-ID names no occurrence, or whose occurrence the series also
|
|
129
|
+
EXDATEs, occupies its own time. The instances of a CalDAV-expanded series are each read, the first
|
|
130
|
+
with or without a RECURRENCE-ID. An event with both DTEND and DURATION is refused (RFC 5545
|
|
131
|
+
§3.6.1). A reader that can name refused series refuses one series at a time and reads the rest of
|
|
132
|
+
the feed; one that cannot refuses the feed, so a refused series is never read as free time.
|
|
133
|
+
|
|
61
134
|
Content not required for conflict decisions should be discarded before reaching an agent. Outbound
|
|
62
135
|
iCalendar updates retain stable UID and monotonic sequence; cancellation is explicit.
|
|
63
136
|
|
|
@@ -71,12 +144,37 @@ the key with drift fails. Cancellation atomically removes derived occupation and
|
|
|
71
144
|
tombstone; delayed create delivery cannot resurrect the UID.
|
|
72
145
|
|
|
73
146
|
Events may carry organizer, attendees, roles, participation status, RSVP requests, display/email
|
|
74
|
-
relative reminders, one RFC 5545 RRULE, and bounded recurrence exceptions. A materialization window
|
|
75
|
-
mandatory for recurring events, must not exceed 367 days, and may produce at most 2,000
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
147
|
+
relative reminders, one RFC 5545 RRULE, and bounded recurrence exceptions. A materialization window
|
|
148
|
+
is mandatory for recurring events, must not exceed 367 days, and may produce at most 2,000
|
|
149
|
+
occurrences. Expansion is bounded by a count of work, never a clock, so an input is accepted or
|
|
150
|
+
refused the same way however loaded the machine: each series may pass 4,000 occurrences from its
|
|
151
|
+
start, and an expansion may take 50,000 steps for an event as it is written and 500,000 for a feed
|
|
152
|
+
or a stored series as maintenance rolls it. A step of ical.js's search costs three, a weekday it
|
|
153
|
+
compares one, a day it walks a fifth, a year it scans three and an eighth for each day that scan
|
|
154
|
+
lists, and a weekday of a year it lists half. Each occurrence passed costs one, each conversion of a
|
|
155
|
+
time in an IANA zone half (one and a half in a zone only a feed defines), and each instant Intl is
|
|
156
|
+
asked for to read a zone's offsets five, charged the first time the expansion reads that day of the
|
|
157
|
+
zone whatever was read before it. Each change listed when a feed's own zone is expanded costs four
|
|
158
|
+
tenths. A zone written the way Outlook writes them, from 1601, costs about 6,000.
|
|
159
|
+
|
|
160
|
+
A stored rule is daily or less frequent, follows RFC 5545 §3.3.10's grammar (every part known and
|
|
161
|
+
given once, `COUNT` at least 1, never `COUNT` with `UNTIL`), and is of a shape the expander reads
|
|
162
|
+
as RFC 5545 does: `BYWEEKNO`, a two-digit `BYDAY` ordinal, `BYSETPOS` other than with
|
|
163
|
+
`FREQ=MONTHLY` and `BYDAY` alone, `BYMONTH` with `FREQ=MONTHLY`, a negative `BYMONTHDAY` other
|
|
164
|
+
than with `FREQ=MONTHLY` and no `BYDAY`, `FREQ=YEARLY` with `BYMONTHDAY` but no `BYMONTH` or with a
|
|
165
|
+
day none of its months has, with `COUNT` or `BYDAY` and a day one of its months can lack, with
|
|
166
|
+
`BYYEARDAY` and `BYMONTH` or `BYMONTHDAY`, or with a numbered `BYDAY` and `BYMONTHDAY`, and time
|
|
167
|
+
parts with `FREQ=MONTHLY` or `YEARLY` are refused, with a reason. A yearly day some years lack (29
|
|
168
|
+
February, or a 31st with April) occurs only in the years that have it (RFC 5545 §3.3.10), so its
|
|
169
|
+
rule must be its series' only source of occurrences, with no RDATE or second RRULE. A DATE `UNTIL`
|
|
170
|
+
on a timed series includes its whole day on the series' clock.
|
|
171
|
+
|
|
172
|
+
A recurrence exception names the occurrence it changes by that occurrence's instant, exported as a
|
|
173
|
+
UTC `RECURRENCE-ID`; one that names no occurrence is refused. Opaque occurrences are reservations
|
|
174
|
+
under the same PostgreSQL EXCLUDE constraint as bookings and holds. Transparent occurrences remain
|
|
175
|
+
queryable but occupy no resource. An event update replaces its materialized set transactionally,
|
|
176
|
+
so an overlap rolls the entire mutation back and preserves the prior event. An exported calendar
|
|
177
|
+
holds a VTIMEZONE (RFC 5545 §3.6.5) for the zone its times name, written from the IANA rules.
|
|
80
178
|
|
|
81
179
|
Full event content is accepted only by the explicit trusted parser. The provider-normalization parser's
|
|
82
180
|
return type contains only identity, recurrence identity, interval, deletion, busy, and revision. A
|
|
@@ -89,7 +187,8 @@ contains the entire window. Any missing, stale, or partial source is returned by
|
|
|
89
187
|
silently treated as free time.
|
|
90
188
|
|
|
91
189
|
An event has a finite, positive duration. A one-off event may last up to 3,660 days and its
|
|
92
|
-
occupation matches the event exactly; a recurring event's occurrences are materialized as above
|
|
190
|
+
occupation matches the event exactly; a recurring event's occurrences are materialized as above,
|
|
191
|
+
each occupying what "Daylight-saving time" says. An
|
|
93
192
|
all-day (`VALUE=DATE`) iCalendar event covers whole local days of the calendar's timezone, from local
|
|
94
193
|
midnight to local midnight, taken from the caller's calendar timezone, else the feed's
|
|
95
194
|
`X-WR-TIMEZONE`; with neither the input is refused, never read in the server's zone. An all-day
|
|
@@ -205,8 +304,10 @@ that span changed, so the passage of time alone announces nothing. It ends a
|
|
|
205
304
|
subscription gracefully, with the listen request's result, at its maximum duration, on shutdown, or
|
|
206
305
|
when a re-read is refused as unauthenticated, unauthorized or not found; it closes the stream without
|
|
207
306
|
a result after three consecutive failed re-reads or when the reader leaves more than 256 events
|
|
208
|
-
unread. Open subscriptions, counted from the moment they start opening, are capped per principal
|
|
209
|
-
per server.
|
|
307
|
+
unread. Open subscriptions, counted from the moment they start opening, are capped per principal,
|
|
308
|
+
per tenant and per server. When the server is full, a listen from a tenant holding fewer than the
|
|
309
|
+
tenant holding most, less one, ends that tenant's oldest open stream gracefully, as at shutdown,
|
|
310
|
+
rather than being refused. A subscription that honors nothing ends right after its acknowledgment.
|
|
210
311
|
|
|
211
312
|
### Discovery and authorization
|
|
212
313
|
|
|
@@ -220,6 +321,32 @@ so each must be a canonical URL without credentials, query or fragment: `https`,
|
|
|
220
321
|
when `allowInsecureLocalhost` is set. Publishing metadata grants nothing: the embedder's
|
|
221
322
|
`authenticate` verifies every token, including its audience.
|
|
222
323
|
|
|
324
|
+
### Scopes, API keys and the application API
|
|
325
|
+
|
|
326
|
+
Every authenticated principal carries a tenant and scopes from `read`, `write` and `admin`. A read
|
|
327
|
+
operation needs `read`; an event write needs `write`. The application API (resources, weekly hours,
|
|
328
|
+
holds, reservations, coverage, every key's events, the tenant itself and maintenance, as REST under
|
|
329
|
+
`/v1` beside the tools) needs `admin`. It is never an MCP tool or an A2A skill and never waits for a
|
|
330
|
+
confirmation. Through it a tenant may cancel any of its events, whichever key wrote it, and, only
|
|
331
|
+
where the server allows it and with its own reference as confirmation, erase itself; it never
|
|
332
|
+
suspends itself or lifts a suspension, which only the deployment role does.
|
|
333
|
+
|
|
334
|
+
An API key is a random secret, stored only as its SHA-256 digest and reached only through
|
|
335
|
+
`SECURITY DEFINER` functions. It acts in the tenant it was created in, with the scopes it was given
|
|
336
|
+
(`read` and `write` by default), until it expires (at most 3,650 days) or is revoked. A tenant holds
|
|
337
|
+
at most 100 active keys and 1,000 in all. The digest of a rotated or erased key is kept, so that key
|
|
338
|
+
is never valid again. A server's own bearer token is never `admin`.
|
|
339
|
+
|
|
340
|
+
A dashboard signs a person in with GitHub, asking for no scope, to the tenant `github:<user id>`,
|
|
341
|
+
and manages that tenant's keys, resources (at most 100) and weekly hours.
|
|
342
|
+
|
|
343
|
+
### Maintenance
|
|
344
|
+
|
|
345
|
+
Recurring events are materialized up to the rolling horizon. Maintenance extends each series to it
|
|
346
|
+
and deletes commands and tombstones past the retention window, in bounded batches; a run that
|
|
347
|
+
reaches its batch limit says so, and the next run continues. `slotlock serve` maintains each tenant
|
|
348
|
+
it serves every hour; `slotlock maintain` and the application API run it on demand.
|
|
349
|
+
|
|
223
350
|
### Principal-scoped events, quotas and retention
|
|
224
351
|
|
|
225
352
|
`createSlotlockStoreAgentBackend` is the canonical binding to the PostgreSQL kernel. It derives an
|
package/agent-server.d.ts
CHANGED
|
@@ -126,7 +126,10 @@ export type SlotlockAgentWriteOperation = Extract<SlotlockAgentOperation, 'slotl
|
|
|
126
126
|
* A listed write first answers `input_required` with a yes/no form describing the change; it runs
|
|
127
127
|
* only when the client retries with the person's explicit acceptance and the sealed state it was
|
|
128
128
|
* given. Where no confirmation is possible — the 2025 MCP revisions and A2A — a listed write fails
|
|
129
|
-
* with `confirmation_required`, never silently runs.
|
|
129
|
+
* with `confirmation_required`, never silently runs. The acceptance comes from the client, which a
|
|
130
|
+
* key holder controls: confirmation keeps an agent from writing unasked, and is not an
|
|
131
|
+
* authorization boundary (scopes and `authorize` are). Off unless set; `slotlock serve` sets it only
|
|
132
|
+
* when SLOTLOCK_CONFIRM_WRITES opts in.
|
|
130
133
|
*/
|
|
131
134
|
export interface SlotlockAgentConfirmationOptions {
|
|
132
135
|
operations: readonly SlotlockAgentWriteOperation[];
|
|
@@ -155,6 +158,12 @@ export interface SlotlockAgentSubscriptionOptions {
|
|
|
155
158
|
keepAliveMs?: number;
|
|
156
159
|
/** Open subscriptions one principal may hold: 1-64, default 4. */
|
|
157
160
|
maxPerPrincipal?: number;
|
|
161
|
+
/**
|
|
162
|
+
* Open subscriptions one tenant may hold across all its principals: 1-10,000, default `maxTotal`.
|
|
163
|
+
* Set it below `maxTotal` on a multi-tenant server, so a tenant with many keys cannot hold every
|
|
164
|
+
* slot.
|
|
165
|
+
*/
|
|
166
|
+
maxPerTenant?: number;
|
|
158
167
|
/** Open subscriptions across the server: 1-10,000, default 256. */
|
|
159
168
|
maxTotal?: number;
|
|
160
169
|
}
|
|
@@ -193,7 +202,7 @@ export type SlotlockAgentServerEvent = {
|
|
|
193
202
|
type: 'subscription';
|
|
194
203
|
outcome: 'opened' | 'closed';
|
|
195
204
|
/** Why a subscription closed. */
|
|
196
|
-
reason?: 'client' | 'duration' | 'shutdown' | 'revoked' | 'failed' | 'empty';
|
|
205
|
+
reason?: 'client' | 'duration' | 'shutdown' | 'revoked' | 'failed' | 'empty' | 'preempted';
|
|
197
206
|
/** How many calendar resources it watched. */
|
|
198
207
|
resources: number;
|
|
199
208
|
};
|
|
@@ -369,7 +378,7 @@ export declare function slotlockOpenApi(options?: {
|
|
|
369
378
|
export declare function slotlockAgentTools(): {
|
|
370
379
|
name: "slotlock_create_event" | "slotlock_delete_event" | "slotlock_find_next_available" | "slotlock_get_event" | "slotlock_get_free_busy" | "slotlock_list_events" | "slotlock_list_resources" | "slotlock_update_event";
|
|
371
380
|
title: "Create resource event" | "Delete resource event" | "Find next available interval" | "Get free-busy" | "Get resource event" | "List calendar resources" | "List resource events" | "Update resource event";
|
|
372
|
-
description: "Apply an expected-revision event patch and rematerialize occupancy atomically." | "Create an event of up to 3,660 days (a booking, lease or long rental); opaque occurrences use the database overlap arbiter." | "Find the earliest certain interval that satisfies the requested duration (up to the 367-day search window, so a multi-month rental slot can be found)." | "List bounded events intersecting a time window." | "List resources visible to the authenticated tenant principal." | "Read one tenant-scoped event by stable identifier." | "Return privacy-minimized busy intervals and explicit coverage certainty." | "Tombstone an event at an expected revision so stale sync cannot resurrect it.";
|
|
381
|
+
description: "Apply an expected-revision event patch and rematerialize occupancy atomically. With expected_resource_id, the resource you read the event on, an event since moved elsewhere is not found." | "Create an event of up to 3,660 days (a booking, lease or long rental); opaque occurrences use the database overlap arbiter." | "Find the earliest certain interval that satisfies the requested duration (up to the 367-day search window, so a multi-month rental slot can be found)." | "List bounded events intersecting a time window." | "List resources visible to the authenticated tenant principal." | "Read one tenant-scoped event by stable identifier." | "Return privacy-minimized busy intervals and explicit coverage certainty." | "Tombstone an event at an expected revision so stale sync cannot resurrect it.";
|
|
373
382
|
inputSchema: Readonly<Record<string, unknown>>;
|
|
374
383
|
outputSchema: Readonly<Record<string, unknown>>;
|
|
375
384
|
annotations: {
|
package/agent-server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { SLOTLOCK_MCP_APPS_EXTENSION, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, clientSupportsSlotlockMcpApp, } from './mcp-app.js';
|
|
4
4
|
import { MCP_META_PROTOCOL_VERSION, MCP_META_SERVER_INFO, MCP_META_SUBSCRIPTION_ID, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, canonicalJson, checkModernMcpRequest, isRecord, openMcpEventStream, openRequestState, principalTag, sealRequestState, sha256Hex, stateSecrets, traceContextFrom, } from './mcp-modern.js';
|
|
5
5
|
import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
|
|
6
|
+
import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
|
|
6
7
|
import { ignoreRejection } from './hooks.js';
|
|
7
8
|
import { slotlockPackageVersion } from './package-version.js';
|
|
8
9
|
import { SLOTLOCK_APPLICATION_REST_ROUTES, SLOTLOCK_REST_ROUTES, matchSlotlockRestRoute, restInputFromBody, restQueryInput, slotlockOpenApiDocument, } from './rest-api.js';
|
|
@@ -61,14 +62,6 @@ const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
|
61
62
|
const EVENT_ID = z.string().trim().min(1).max(500);
|
|
62
63
|
const IDEMPOTENCY_KEY = z.string().trim().min(1).max(200);
|
|
63
64
|
const REVISION = z.number().int().positive();
|
|
64
|
-
// Output bounds mirror the trusted store's byte ceilings. A value admitted by a UTF-8 byte
|
|
65
|
-
// ceiling can never exceed that many UTF-16 code units, so every valid stored/provider event is
|
|
66
|
-
// serializable without truncating recurrence semantics or calendar identities.
|
|
67
|
-
const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
|
|
68
|
-
const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
|
|
69
|
-
const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
|
|
70
|
-
const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
|
|
71
|
-
const TRUSTED_REMINDER_MINUTES_OUTPUT_MAX = 366 * 24 * 60;
|
|
72
65
|
const MAX_AGENT_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
|
|
73
66
|
/** The longest slot `slotlock_find_next_available` can look for: the whole search window. */
|
|
74
67
|
const MAX_SLOT_MINUTES = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60;
|
|
@@ -95,41 +88,6 @@ const COVERAGE = z
|
|
|
95
88
|
reason: z.string().max(100).nullable(),
|
|
96
89
|
})
|
|
97
90
|
.strict();
|
|
98
|
-
const ORGANIZER = z
|
|
99
|
-
.object({
|
|
100
|
-
address: z.string().email().max(320),
|
|
101
|
-
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
102
|
-
})
|
|
103
|
-
.strict();
|
|
104
|
-
const ATTENDEE = z
|
|
105
|
-
.object({
|
|
106
|
-
address: z.string().email().max(320),
|
|
107
|
-
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
108
|
-
role: z.enum(['chair', 'required', 'optional', 'non_participant']),
|
|
109
|
-
participation_status: z.enum([
|
|
110
|
-
'needs_action',
|
|
111
|
-
'accepted',
|
|
112
|
-
'declined',
|
|
113
|
-
'tentative',
|
|
114
|
-
'delegated',
|
|
115
|
-
]),
|
|
116
|
-
rsvp: z.boolean(),
|
|
117
|
-
})
|
|
118
|
-
.strict();
|
|
119
|
-
const REMINDER = z
|
|
120
|
-
.object({
|
|
121
|
-
minutes_before: z.number().int().min(0).max(TRUSTED_REMINDER_MINUTES_OUTPUT_MAX),
|
|
122
|
-
channel: z.enum(['display', 'email']),
|
|
123
|
-
})
|
|
124
|
-
.strict();
|
|
125
|
-
const RECURRENCE_EXCEPTION = z
|
|
126
|
-
.object({
|
|
127
|
-
recurrence_id: DATE_TIME,
|
|
128
|
-
cancelled: z.boolean(),
|
|
129
|
-
starts_at: DATE_TIME.nullable(),
|
|
130
|
-
ends_at: DATE_TIME.nullable(),
|
|
131
|
-
})
|
|
132
|
-
.strict();
|
|
133
91
|
const ORGANIZER_INPUT = z
|
|
134
92
|
.object({
|
|
135
93
|
address: z.string().email().max(320),
|
|
@@ -187,30 +145,6 @@ const MCP_INITIALIZE_PARAMS = z
|
|
|
187
145
|
_meta: z.record(z.string(), z.unknown()).optional(),
|
|
188
146
|
})
|
|
189
147
|
.passthrough();
|
|
190
|
-
const EVENT = z
|
|
191
|
-
.object({
|
|
192
|
-
id: EVENT_ID,
|
|
193
|
-
resource_id: RESOURCE_ID,
|
|
194
|
-
starts_at: DATE_TIME,
|
|
195
|
-
ends_at: DATE_TIME,
|
|
196
|
-
timezone: z.string().min(1).max(100),
|
|
197
|
-
title: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
198
|
-
description: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
199
|
-
location: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
200
|
-
organizer: ORGANIZER.nullable(),
|
|
201
|
-
attendees: z.array(ATTENDEE).max(100),
|
|
202
|
-
reminders: z.array(REMINDER).max(20),
|
|
203
|
-
status: z.enum(['tentative', 'confirmed', 'cancelled']),
|
|
204
|
-
transparency: z.enum(['opaque', 'transparent']),
|
|
205
|
-
sequence: z.number().int().nonnegative(),
|
|
206
|
-
revision: REVISION,
|
|
207
|
-
recurrence_rule: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
208
|
-
recurrence_exceptions: z
|
|
209
|
-
.array(RECURRENCE_EXCEPTION)
|
|
210
|
-
.max(TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX),
|
|
211
|
-
recurrence_id: DATE_TIME.nullable(),
|
|
212
|
-
})
|
|
213
|
-
.strict();
|
|
214
148
|
/**
|
|
215
149
|
* Names earlier builds published, which every entry point still accepts and dispatches as the same
|
|
216
150
|
* operation; nothing advertises them. The dotted names came first, before tool names had to be
|
|
@@ -427,7 +361,7 @@ const OPERATION_DEFINITIONS = [
|
|
|
427
361
|
{
|
|
428
362
|
name: 'slotlock_update_event',
|
|
429
363
|
title: 'Update resource event',
|
|
430
|
-
description: 'Apply an expected-revision event patch and rematerialize occupancy atomically.',
|
|
364
|
+
description: 'Apply an expected-revision event patch and rematerialize occupancy atomically. With expected_resource_id, the resource you read the event on, an event since moved elsewhere is not found.',
|
|
431
365
|
input: z
|
|
432
366
|
.object({
|
|
433
367
|
event_id: EVENT_ID,
|
|
@@ -447,6 +381,8 @@ const OPERATION_DEFINITIONS = [
|
|
|
447
381
|
recurrence_rule: RECURRENCE_RULE.nullable().optional(),
|
|
448
382
|
recurrence_exceptions: z.array(RECURRENCE_EXCEPTION_INPUT).max(500).optional(),
|
|
449
383
|
idempotency_key: IDEMPOTENCY_KEY,
|
|
384
|
+
// Last, so the generated clients that take optional arguments by position keep their order.
|
|
385
|
+
expected_resource_id: RESOURCE_ID.optional(),
|
|
450
386
|
})
|
|
451
387
|
.strict()
|
|
452
388
|
.refine((value) => value.starts_at !== undefined ||
|
|
@@ -1152,12 +1088,14 @@ function validateSubscriptions(value) {
|
|
|
1152
1088
|
}
|
|
1153
1089
|
return setting;
|
|
1154
1090
|
};
|
|
1091
|
+
const maxTotal = bounded('maxTotal', 1, 10_000, 256);
|
|
1155
1092
|
return {
|
|
1156
1093
|
pollIntervalMs: bounded('pollIntervalMs', 1_000, 300_000, 10_000),
|
|
1157
1094
|
maxDurationMs: bounded('maxDurationMs', 60_000, 86_400_000, 900_000),
|
|
1158
1095
|
keepAliveMs: bounded('keepAliveMs', 1_000, 60_000, 15_000),
|
|
1159
1096
|
maxPerPrincipal: bounded('maxPerPrincipal', 1, 64, 4),
|
|
1160
|
-
|
|
1097
|
+
maxPerTenant: bounded('maxPerTenant', 1, 10_000, maxTotal),
|
|
1098
|
+
maxTotal,
|
|
1161
1099
|
};
|
|
1162
1100
|
}
|
|
1163
1101
|
/**
|
|
@@ -1451,10 +1389,14 @@ function promptSeries(series, zone) {
|
|
|
1451
1389
|
const booked = expandCalendarEventOccurrences(content, horizon);
|
|
1452
1390
|
beyond = promptSeriesEnd(series.rule, booked.length, horizon.end);
|
|
1453
1391
|
const length = end.getTime() - start.getTime();
|
|
1392
|
+
// Moved: by an exception, to another time or length. One the clocks change inside, or that
|
|
1393
|
+
// the store ends where the next starts, is where its rule puts it.
|
|
1394
|
+
const excepted = new Set(exceptions.flatMap((exception) => exception.cancelled !== true && exception.start ? [exception.recurrenceId.getTime()] : []));
|
|
1454
1395
|
moved = promptMoved(booked.flatMap((occurrence) => {
|
|
1455
1396
|
const from = new Date(occurrence.recurrenceId);
|
|
1456
|
-
const shifted =
|
|
1457
|
-
occurrence.
|
|
1397
|
+
const shifted = excepted.has(from.getTime()) &&
|
|
1398
|
+
(occurrence.start.getTime() !== from.getTime() ||
|
|
1399
|
+
occurrence.end.getTime() - occurrence.start.getTime() !== length);
|
|
1458
1400
|
return shifted && Number.isFinite(from.getTime())
|
|
1459
1401
|
? [{ from, start: occurrence.start, end: occurrence.end }]
|
|
1460
1402
|
: [];
|
|
@@ -1673,8 +1615,18 @@ async function confirmationMessage(operation, input, lookup) {
|
|
|
1673
1615
|
changes.push('no longer repeats');
|
|
1674
1616
|
}
|
|
1675
1617
|
else if (typeof rule === 'string' && reshaped && seriesStart && seriesEnd) {
|
|
1676
|
-
const seriesZone = input.timezone ?? event?.timezone
|
|
1677
|
-
|
|
1618
|
+
const seriesZone = input.timezone ?? event?.timezone;
|
|
1619
|
+
if (seriesZone === undefined) {
|
|
1620
|
+
// An unread event and no new zone: the store expands in the event's own, which the question
|
|
1621
|
+
// cannot know, so it states no dates.
|
|
1622
|
+
changes.push(`repeats ${promptRule(rule)}, on dates that follow the event's existing time zone`);
|
|
1623
|
+
if (input.recurrence_exceptions !== undefined) {
|
|
1624
|
+
changes.push(`exceptions to ${promptRequestedExceptions(input.recurrence_exceptions, zone)}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
else {
|
|
1628
|
+
changes.push(`so it books ${promptSeries({ start: seriesStart, end: seriesEnd, timezone: seriesZone, rule, exceptions }, zone)}`);
|
|
1629
|
+
}
|
|
1678
1630
|
}
|
|
1679
1631
|
else {
|
|
1680
1632
|
if (typeof input.recurrence_rule === 'string') {
|
|
@@ -1771,6 +1723,22 @@ function a2aError(id, code, message, info) {
|
|
|
1771
1723
|
},
|
|
1772
1724
|
});
|
|
1773
1725
|
}
|
|
1726
|
+
/**
|
|
1727
|
+
* The request's principal, or the error when `authenticate` throws a 503
|
|
1728
|
+
* `SlotlockAgentOperationError`, as createSlotlockApiKeyAuthenticator does when its lookup queue is
|
|
1729
|
+
* full: no answer about the credential, so the client is told to retry rather than that it is
|
|
1730
|
+
* anonymous.
|
|
1731
|
+
*/
|
|
1732
|
+
async function principalOrBusy(authenticate, request) {
|
|
1733
|
+
try {
|
|
1734
|
+
return await authenticate(request);
|
|
1735
|
+
}
|
|
1736
|
+
catch (error) {
|
|
1737
|
+
if (error instanceof SlotlockAgentOperationError && error.status === 503)
|
|
1738
|
+
return error;
|
|
1739
|
+
throw error;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1774
1742
|
/** Map a request path onto the server's base path; `null` when it lies outside it. */
|
|
1775
1743
|
function routePath(pathname, basePath) {
|
|
1776
1744
|
if (basePath === '')
|
|
@@ -2185,15 +2153,48 @@ export function createSlotlockAgentServer(options) {
|
|
|
2185
2153
|
return modernError(503, id, -32603, 'Server is shutting down');
|
|
2186
2154
|
const principalKey = `${principal.tenantRef}\0${principal.subject}`;
|
|
2187
2155
|
let held = 0;
|
|
2188
|
-
|
|
2156
|
+
let heldByTenant = 0;
|
|
2157
|
+
for (const open of openSubscriptions) {
|
|
2189
2158
|
if (open.principalKey === principalKey)
|
|
2190
2159
|
held += 1;
|
|
2160
|
+
if (open.tenantRef === principal.tenantRef)
|
|
2161
|
+
heldByTenant += 1;
|
|
2162
|
+
}
|
|
2163
|
+
if (openSubscriptions.size >= subscriptionPolicy.maxTotal &&
|
|
2164
|
+
held < subscriptionPolicy.maxPerPrincipal &&
|
|
2165
|
+
heldByTenant < subscriptionPolicy.maxPerTenant) {
|
|
2166
|
+
// A full server is shared out: a tenant holding fewer than the one holding most, less one,
|
|
2167
|
+
// ends that tenant's oldest stream, so many tenants within their share cannot keep another
|
|
2168
|
+
// from holding any. A slot still opening has nothing to end yet.
|
|
2169
|
+
const streamsOf = new Map();
|
|
2170
|
+
for (const open of openSubscriptions) {
|
|
2171
|
+
if (!open.streaming)
|
|
2172
|
+
continue;
|
|
2173
|
+
const streams = streamsOf.get(open.tenantRef);
|
|
2174
|
+
if (streams)
|
|
2175
|
+
streams.push(open);
|
|
2176
|
+
else
|
|
2177
|
+
streamsOf.set(open.tenantRef, [open]);
|
|
2178
|
+
}
|
|
2179
|
+
let most = [];
|
|
2180
|
+
for (const streams of streamsOf.values())
|
|
2181
|
+
if (streams.length > most.length)
|
|
2182
|
+
most = streams;
|
|
2183
|
+
if (most.length > heldByTenant + 1)
|
|
2184
|
+
most[0]?.end('preempted');
|
|
2185
|
+
}
|
|
2191
2186
|
if (openSubscriptions.size >= subscriptionPolicy.maxTotal ||
|
|
2192
|
-
held >= subscriptionPolicy.maxPerPrincipal
|
|
2187
|
+
held >= subscriptionPolicy.maxPerPrincipal ||
|
|
2188
|
+
heldByTenant >= subscriptionPolicy.maxPerTenant) {
|
|
2193
2189
|
return modernError(429, id, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, 'Too many subscriptions');
|
|
2194
2190
|
}
|
|
2195
2191
|
// The slot is held while access is checked, so concurrent opens cannot overshoot the caps.
|
|
2196
|
-
const slot = {
|
|
2192
|
+
const slot = {
|
|
2193
|
+
principalKey,
|
|
2194
|
+
tenantRef: principal.tenantRef,
|
|
2195
|
+
streaming: false,
|
|
2196
|
+
end: () => { },
|
|
2197
|
+
};
|
|
2197
2198
|
openSubscriptions.add(slot);
|
|
2198
2199
|
let streaming = false;
|
|
2199
2200
|
try {
|
|
@@ -2257,6 +2258,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
2257
2258
|
});
|
|
2258
2259
|
}
|
|
2259
2260
|
slot.end = end;
|
|
2261
|
+
slot.streaming = true;
|
|
2260
2262
|
stream.send({
|
|
2261
2263
|
jsonrpc: '2.0',
|
|
2262
2264
|
method: 'notifications/subscriptions/acknowledged',
|
|
@@ -2463,7 +2465,10 @@ export function createSlotlockAgentServer(options) {
|
|
|
2463
2465
|
return refuse(403, 'origin_not_allowed');
|
|
2464
2466
|
}
|
|
2465
2467
|
}
|
|
2466
|
-
const principal = await options.authenticate
|
|
2468
|
+
const principal = await principalOrBusy(options.authenticate, request);
|
|
2469
|
+
if (principal instanceof SlotlockAgentOperationError) {
|
|
2470
|
+
return refuse(503, principal.code, { 'Retry-After': '1' });
|
|
2471
|
+
}
|
|
2467
2472
|
if (!principal ||
|
|
2468
2473
|
!isCanonicalPrincipalIdentity(principal.subject) ||
|
|
2469
2474
|
!isCanonicalPrincipalIdentity(principal.tenantRef)) {
|
|
@@ -2516,6 +2521,21 @@ export function createSlotlockAgentServer(options) {
|
|
|
2516
2521
|
if (Object.keys(params).some((name) => Object.hasOwn(given, name))) {
|
|
2517
2522
|
return refuseCall(400, 'invalid_arguments');
|
|
2518
2523
|
}
|
|
2524
|
+
// An application write's retry identity may come as the Idempotency-Key header, where HTTP
|
|
2525
|
+
// clients put it, as well as its `idempotency_key` argument. On an operation that keeps none it
|
|
2526
|
+
// is refused rather than ignored: a key that was dropped would promise a replay it never gives.
|
|
2527
|
+
// The agent tools take theirs as an argument alone, as they always have.
|
|
2528
|
+
const idempotencyHeader = request.headers.get('idempotency-key');
|
|
2529
|
+
if (idempotencyHeader !== null && isSlotlockApplicationOperation(route.operation)) {
|
|
2530
|
+
const fields = (restInputSchemas.get(route.operation)?.properties ?? {});
|
|
2531
|
+
if (!Object.hasOwn(fields, 'idempotency_key')) {
|
|
2532
|
+
return refuseCall(400, 'idempotency_key_not_supported');
|
|
2533
|
+
}
|
|
2534
|
+
if (Object.hasOwn(given, 'idempotency_key') && given.idempotency_key !== idempotencyHeader) {
|
|
2535
|
+
return refuseCall(400, 'invalid_arguments');
|
|
2536
|
+
}
|
|
2537
|
+
given = { ...given, idempotency_key: idempotencyHeader };
|
|
2538
|
+
}
|
|
2519
2539
|
const outcome = isSlotlockApplicationOperation(route.operation)
|
|
2520
2540
|
? await invokeApplicationOperation(slotlockApplicationOperation(route.operation), principal, { ...given, ...params }, request, trace)
|
|
2521
2541
|
: await invokeSlotlockAgentOperation({
|
|
@@ -2607,7 +2627,17 @@ export function createSlotlockAgentServer(options) {
|
|
|
2607
2627
|
return json(403, rpcError(null, -32600, 'Origin not allowed'));
|
|
2608
2628
|
}
|
|
2609
2629
|
}
|
|
2610
|
-
const principal = await options.authenticate
|
|
2630
|
+
const principal = await principalOrBusy(options.authenticate, request);
|
|
2631
|
+
if (principal instanceof SlotlockAgentOperationError) {
|
|
2632
|
+
// As `slotlock serve` answers it: A2A names the reason in an ErrorInfo, as for a rate limit.
|
|
2633
|
+
const error = { code: -32603, message: 'Server is busy' };
|
|
2634
|
+
const info = {
|
|
2635
|
+
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
|
2636
|
+
reason: 'SERVER_BUSY',
|
|
2637
|
+
domain: 'slotlock',
|
|
2638
|
+
};
|
|
2639
|
+
return json(503, { jsonrpc: '2.0', id: null, error: path === '/a2a' ? { ...error, data: [info] } : error }, { 'Retry-After': '1' });
|
|
2640
|
+
}
|
|
2611
2641
|
if (!principal ||
|
|
2612
2642
|
!isCanonicalPrincipalIdentity(principal.subject) ||
|
|
2613
2643
|
!isCanonicalPrincipalIdentity(principal.tenantRef)) {
|
package/agent-store-backend.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface SlotlockStoreApplicationBackendOptions {
|
|
|
27
27
|
maxTenantResources?: number;
|
|
28
28
|
/** Batches of each maintenance task one `slotlock_run_maintenance` call runs, 1-100 (default 10). */
|
|
29
29
|
maintenanceBatches?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Let an admin key erase its own tenant (`slotlock_erase_tenant`), everything it has and every
|
|
32
|
+
* key it holds, with its tenant reference as confirmation. Off unless true: erasure cannot be
|
|
33
|
+
* undone, and an operator may owe a tenant's data a retention or review first.
|
|
34
|
+
*/
|
|
35
|
+
tenantErasure?: boolean;
|
|
30
36
|
}
|
|
31
37
|
/**
|
|
32
38
|
* Serve the application API (application-api.ts) from the production store, in the principal's
|