slotlock 0.5.3 → 0.6.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 +101 -5
- package/README.md +65 -51
- package/SECURITY.md +26 -13
- package/agent-server.d.ts +9 -0
- package/agent-server.js +29 -3
- package/application-api.js +6 -3
- package/ddl.d.ts +20 -3
- package/ddl.js +164 -7
- package/event-feed.d.ts +25 -8
- package/event-feed.js +45 -14
- package/index.d.ts +1 -1
- package/openapi.json +27 -2
- package/package.json +1 -1
- package/rest-api.js +12 -0
- package/self-host.js +50 -2
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +4 -1
- package/store.js +133 -21
- package/webhooks.d.ts +3 -2
- package/webhooks.js +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,8 +3,104 @@
|
|
|
3
3
|
All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
|
|
4
4
|
versioning.
|
|
5
5
|
|
|
6
|
+
## 0.6.0 - 2026-10-29
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `invalid_arguments` from argument validation names the fields it refused: `issues`, up to 20
|
|
11
|
+
`{path, message}` (the field's path in the arguments, outermost first; `[]` is the arguments as a
|
|
12
|
+
whole). REST adds it to the problem details beside `code` (documented in openapi.json's `Error`),
|
|
13
|
+
MCP and A2A to `error`. Additive: `code` and the status are unchanged.
|
|
14
|
+
`invokeSlotlockAgentOperation` returns it too (`SlotlockArgumentIssue`).
|
|
15
|
+
- `slotlock serve` logs one `serving_as_schema_owner` warning at startup when its role owns the
|
|
16
|
+
`slotlock` schema: row-level security does not confine the owner on tables that do not force
|
|
17
|
+
it. It still serves.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Security: a coverage source's `source_id` could be confirmed by guessing. 0.5.3 named a source on the feed
|
|
22
|
+
by `src_` and 24 hex digits of an unkeyed SHA-256 of the tenant and the source, so anyone who saw
|
|
23
|
+
a change or a webhook and knew the tenant could test a guessed source offline: an address such as
|
|
24
|
+
`google:alice.smith@example.com`, say. A source's `source_id` is now random (`src_` and 32 hex
|
|
25
|
+
digits, 122 bits from `gen_random_uuid()`), stored with the source in
|
|
26
|
+
`calendar_coverage.source_id` when it is first recorded for the resource and kept until it is
|
|
27
|
+
deleted. Nothing outside the database can work it out; the tenant reads it beside the source from
|
|
28
|
+
`listCoverage` and `slotlock_get_coverage`.
|
|
29
|
+
- Security: changes an earlier release wrote still carried the source. Before 0.5.3 `coverage.stale` had the
|
|
30
|
+
source itself as `subject` and `data.source` (an iCalendar URL with its token) and the error read
|
|
31
|
+
from it as `data.last_error`, and `listChanges`, the history and webhooks that had not received it
|
|
32
|
+
served it until it was pruned. The upgrade renames every `coverage.*` change not in this release's
|
|
33
|
+
shape: `subject` and `data.source_id` become the source's new id, `source` and `last_error` are
|
|
34
|
+
removed. A change about a source deleted since is named `src_redacted`: nothing can match it any
|
|
35
|
+
more, and a made-up id would look like a source the tenant could find. A change an earlier
|
|
36
|
+
release's server (0.3-0.5) still running writes after the upgrade is put in the same shape as it
|
|
37
|
+
is inserted, by a trigger on the outbox (`slotlock.outbox_coverage_in_shape`), so none is stored
|
|
38
|
+
with its source. The read path redacts too: `listChanges`, `listRecentChanges`, `listHistory`,
|
|
39
|
+
webhook deliveries and `slotlockCloudEvent` never serve a `coverage.*` change with `source` or
|
|
40
|
+
`last_error`, and name one whose subject is not a `source_id` of this release `src_redacted`;
|
|
41
|
+
history by `subject` never finds a coverage change by any other name, so it cannot confirm a
|
|
42
|
+
guessed source either.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **Breaking:** every `source_id` changes, and its shape is `src_` and 32 hex digits (was 24). A
|
|
47
|
+
reader that stored `source_id`s or `subject`s of `coverage.*` changes matches them again from
|
|
48
|
+
`listCoverage` / `GET /v1/resources/{id}/coverage`. The same source recorded for two resources,
|
|
49
|
+
or deleted and recorded again, has a different id each time.
|
|
50
|
+
- **Breaking:** changes already on the feed about coverage are rewritten in place by the upgrade, as
|
|
51
|
+
above; their positions and ids (`webhook-id`) do not change, so a webhook that already received
|
|
52
|
+
one is not sent it again, and one that has not receives the new shape.
|
|
53
|
+
|
|
54
|
+
- **Breaking:** `slotlockCoverageSourceId` throws (`coverage_source_id_unavailable`) instead of
|
|
55
|
+
returning 0.5.3's digest, which nothing carries any more and which would confirm a guessed source:
|
|
56
|
+
read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`) instead.
|
|
57
|
+
- `slotlock migrate` (and `serve --migrate`) run as a role that does not own the schema, with no
|
|
58
|
+
`SLOTLOCK_MIGRATE_DATABASE_URL`, now fail with a first line that says to set
|
|
59
|
+
`SLOTLOCK_MIGRATE_DATABASE_URL` to the schema owner's URL, then the database's refusal.
|
|
60
|
+
- The README no longer opens with the 0.2.0 known issues: they are in
|
|
61
|
+
`docs/release/0.2.0-known-issues.md`, linked under "Supported versions".
|
|
62
|
+
- The hosted service page, its site page and SECURITY.md say the hosted service is not open and
|
|
63
|
+
that self-hosting is the only supported path today; SECURITY.md no longer invites testing it.
|
|
64
|
+
|
|
65
|
+
### Deprecated
|
|
66
|
+
|
|
67
|
+
- `slotlockCoverageSourceId`, which now only throws. It may be removed in 0.7.0.
|
|
68
|
+
|
|
69
|
+
### Upgrading
|
|
70
|
+
|
|
71
|
+
- **Breaking:** the schema is version 4. A 0.5 release refuses to migrate it ("Slotlock will not
|
|
72
|
+
migrate a schema a newer release migrated") and its server refuses to serve it, since its DDL would
|
|
73
|
+
put back the trigger that names a source by its guessable digest. Rolling back to 0.5.x needs a
|
|
74
|
+
restore of a backup taken before the upgrade.
|
|
75
|
+
- The migration adds `calendar_coverage.source_id` without rewriting the table (the column bare, its
|
|
76
|
+
random default set apart), gives each existing source an id under the migration's lock
|
|
77
|
+
(`calendar_coverage` is one row per source and resource), and adds its CHECK NOT VALID, validated
|
|
78
|
+
after the migration commits.
|
|
79
|
+
- Changes written from the upgrade on are put in shape as they are inserted. Those written before
|
|
80
|
+
it: once the structure commits, `applySchema` renames them in batches of 5,000 outbox ids by
|
|
81
|
+
primary key, each its own transaction, up to the last change when the pass started, read in a
|
|
82
|
+
short transaction of its own once the outbox writes in flight have ended. Where it got to is saved
|
|
83
|
+
with each batch (`schema_version.coverage_changes_renamed_through`): an interrupted pass goes on
|
|
84
|
+
from there, and the next migration reads only what was written since, until a pass after the
|
|
85
|
+
upgrade's finds nothing to rename and marks it done (`coverage_changes_renamed`). After that no
|
|
86
|
+
migration reads the outbox for it again.
|
|
87
|
+
- Locks: under a deployment role that forced RLS binds, each batch lifts FORCE from
|
|
88
|
+
`calendar_coverage` and `outbox` and puts it back before it commits, holding ACCESS EXCLUSIVE on
|
|
89
|
+
both for that batch (one range of 5,000 ids: milliseconds), so writes to either table wait that
|
|
90
|
+
long, once per batch, for every batch of the outbox on the upgrade and for the rows written since
|
|
91
|
+
on the passes after it. A superuser or BYPASSRLS role holds SHARE ROW EXCLUSIVE on the outbox
|
|
92
|
+
for the short transaction that reads the head (writes wait, reads do not), then only the row
|
|
93
|
+
locks of the rows it renames. `SLOTLOCK_CORE_DDL`, applied with psql, renames in its one transaction instead, with
|
|
94
|
+
the outbox under ACCESS EXCLUSIVE for the whole of it: a scan of the whole outbox on the upgrade.
|
|
95
|
+
- A migration step ended as a deadlock's victim (a caller's transaction that took the outbox, then a
|
|
96
|
+
coverage row, while a rename batch took them in the other order, say) is retried like one that
|
|
97
|
+
waited past its lock timeout, up to `attempts`, instead of failing.
|
|
98
|
+
|
|
6
99
|
## 0.5.3 - 2026-10-28
|
|
7
100
|
|
|
101
|
+
0.5.3 shipped breaking changes in a patch release, contrary to [VERSIONING.md](VERSIONING.md): the
|
|
102
|
+
two marked "Breaking" under **Changed**. Breaking changes will be minor releases from now on.
|
|
103
|
+
|
|
8
104
|
### Added
|
|
9
105
|
|
|
10
106
|
- Configuration changes on the feed and in history (RFC 0002 P1-5, P1-6), each in the change's own
|
|
@@ -93,11 +189,11 @@ versioning.
|
|
|
93
189
|
|
|
94
190
|
### Changed
|
|
95
191
|
|
|
96
|
-
-
|
|
97
|
-
filter receives them too; filter by type to keep only bookings.
|
|
98
|
-
- `coverage.stale` names its source by `source_id`, as `subject` and in `data`, in
|
|
99
|
-
and no longer carries `last_error`: a source can be an iCalendar URL with a
|
|
100
|
-
and an error can quote it. `slotlock_get_coverage` still shows both to the tenant, with the
|
|
192
|
+
- Breaking: the feed now also records configuration changes, so a reader or webhook with no
|
|
193
|
+
`event_types` filter receives them too; filter by type to keep only bookings.
|
|
194
|
+
- Breaking: `coverage.stale` names its source by `source_id`, as `subject` and in `data`, in
|
|
195
|
+
place of `source`, and no longer carries `last_error`: a source can be an iCalendar URL with a
|
|
196
|
+
token or an address, and an error can quote it. `slotlock_get_coverage` still shows both to the tenant, with the
|
|
101
197
|
`source_id` to match a change to its source. Changes already on the feed keep the old payload
|
|
102
198
|
until they are pruned.
|
|
103
199
|
|
package/README.md
CHANGED
|
@@ -5,44 +5,6 @@ reservable, answers "when is this free?" from weekly availability rules, and let
|
|
|
5
5
|
double bookings, however many writers race. Agents call it over MCP or A2A; your code calls it as a
|
|
6
6
|
TypeScript library.
|
|
7
7
|
|
|
8
|
-
## Known issues in 0.2.0
|
|
9
|
-
|
|
10
|
-
Slotlock 0.2.0 can occupy less time than an event was written with, so another booking can take the
|
|
11
|
-
rest: around daylight-saving changes, in any time zone that has them, and at the end of some series.
|
|
12
|
-
The library and `slotlock serve` are both affected (the last case below is in the library's
|
|
13
|
-
iCalendar readers only); the 0.1 releases are older still and unsupported. 0.3.0, released on
|
|
14
|
-
2026-10-27, fixes all six cases: upgrade (`npm install slotlock@0.3.0` or
|
|
15
|
-
`ghcr.io/pilotaai/slotlock:0.3.0`, then `slotlock migrate`, as the [changelog](CHANGELOG.md) says).
|
|
16
|
-
While you run 0.2.0, do not rely on it alone to refuse bookings in them.
|
|
17
|
-
|
|
18
|
-
- A recurring event whose first occurrence spans a daylight-saving change gets that occurrence an
|
|
19
|
-
hour short: in spring, an hour of the written time is free to book.
|
|
20
|
-
- Recurring events written through the store (the agent tools and the application API) lose the
|
|
21
|
-
repeated hour of an autumn change: times in it move an hour early, and a series whose first
|
|
22
|
-
occurrence spans it can store no occurrences, so all of its time is free.
|
|
23
|
-
- A stored recurring series cannot cancel or move an occurrence that falls in the skipped hour of a
|
|
24
|
-
spring change; the time it was moved to stays free.
|
|
25
|
-
- An occurrence that starts in the skipped hour and lasts longer than the skipped hour is cut short:
|
|
26
|
-
a 75-minute occurrence occupies 15 minutes.
|
|
27
|
-
- A timed recurring series with a date-only `UNTIL` ends at midnight UTC, so it can lose its last
|
|
28
|
-
day.
|
|
29
|
-
- An event read from iCalendar (`parseICalendarChanges`, `parseTrustedICalendarEvents` or the
|
|
30
|
-
worker pool) whose `DURATION` counts hours loses an hour across a spring change, whether it is a
|
|
31
|
-
single event, a series, an `RDATE` period or a moved occurrence: `DURATION:PT48H` occupies 47
|
|
32
|
-
hours.
|
|
33
|
-
|
|
34
|
-
0.1.0, 0.1.1 and 0.2.0 also pin zod 4.6.5 exactly, which has a high advisory
|
|
35
|
-
([GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5): validating an oversized
|
|
36
|
-
array can exhaust memory), which `npm audit` reports. While you run one of them, add this to your
|
|
37
|
-
application's `package.json` and run `npm install`:
|
|
38
|
-
|
|
39
|
-
```json
|
|
40
|
-
"overrides": {"zod": "4.6.7"}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
The full notice, with the versions affected, is in
|
|
44
|
-
[docs/release/0.2.0-known-issues.md](https://github.com/PILOTAAI/slotlock/blob/main/docs/release/0.2.0-known-issues.md).
|
|
45
|
-
|
|
46
8
|
## Why it is built for agents
|
|
47
9
|
|
|
48
10
|
- Resources own calendars, not users, and the tenant comes only from the authenticated caller.
|
|
@@ -192,10 +154,11 @@ slotlock rules clear --resource vehicle-42
|
|
|
192
154
|
|
|
193
155
|
With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
|
|
194
156
|
`npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
|
|
195
|
-
put `slotlock` on your `PATH`). Use two roles, as Compose does:
|
|
196
|
-
|
|
197
|
-
connects as the
|
|
198
|
-
superuser or a `BYPASSRLS` role, which row-level security does not
|
|
157
|
+
put `slotlock` on your `PATH`). Use two roles, as Compose does, so two URLs: `serve` connects as
|
|
158
|
+
`DATABASE_URL`, a role that owns nothing and cannot bypass row-level security, and `migrate`
|
|
159
|
+
connects as `SLOTLOCK_MIGRATE_DATABASE_URL`, the schema owner, and grants that role what it needs.
|
|
160
|
+
`serve` refuses to start as a superuser or a `BYPASSRLS` role, which row-level security does not
|
|
161
|
+
confine, and warns (`serving_as_schema_owner`) when its role owns the schema.
|
|
199
162
|
|
|
200
163
|
```sh
|
|
201
164
|
npm ci && npm run build
|
|
@@ -228,7 +191,7 @@ slotlock key revoke <id>
|
|
|
228
191
|
|
|
229
192
|
- A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
|
|
230
193
|
- It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
|
|
231
|
-
- `read` covers the
|
|
194
|
+
- `read` covers the seven tools that only look; `write` covers create, update and delete; `admin`
|
|
232
195
|
covers the [application API](#application-api), which no agent needs. Default: read and write.
|
|
233
196
|
- `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
|
|
234
197
|
- A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
|
|
@@ -596,6 +559,9 @@ export async function bookHandover(store: SlotlockStore, tenantRef: string) {
|
|
|
596
559
|
}
|
|
597
560
|
```
|
|
598
561
|
|
|
562
|
+
Run every store method inside `withTenant`. A call outside it is not refused: it runs with no tenant
|
|
563
|
+
context, so as the application role forced row-level security shows it no rows.
|
|
564
|
+
|
|
599
565
|
`withTenant` validates the tenant, sets the context transaction-locally on the one connection every
|
|
600
566
|
callback call uses, supports nested savepoints, and restores the previous context. Do not issue a
|
|
601
567
|
standalone `set_config(..., true)` through a pool: its transaction ends before the next call.
|
|
@@ -648,6 +614,49 @@ Writing rules:
|
|
|
648
614
|
reference for a genuinely new event.
|
|
649
615
|
- `listCalendarEvents` and `listResources` return at most 1,000 rows and page with keyset cursors.
|
|
650
616
|
|
|
617
|
+
### Holds, external bookings and the feed
|
|
618
|
+
|
|
619
|
+
<!-- example: examples/store.ts#holds -->
|
|
620
|
+
```ts
|
|
621
|
+
export async function holdBookAndFollow(store: SlotlockStore, tenantRef: string, resourceId: string) {
|
|
622
|
+
// Inside withTenant: outside it the application role's row-level security shows no rows.
|
|
623
|
+
const booked = await store.withTenant(tenantRef, async (tenant) => {
|
|
624
|
+
// Hold a window for ten minutes while the customer pays, then turn it into a booking.
|
|
625
|
+
const held = await tenant.acquireHold({
|
|
626
|
+
tenantRef,
|
|
627
|
+
resourceId,
|
|
628
|
+
start: new Date('2027-04-05T09:00:00Z'),
|
|
629
|
+
end: new Date('2027-04-05T10:00:00Z'),
|
|
630
|
+
ttlMs: 10 * 60_000,
|
|
631
|
+
});
|
|
632
|
+
if (!held.ok) throw new Error(`not held: ${held.code}`);
|
|
633
|
+
const confirmed = await tenant.confirmHold(held.hold.id, { tenantRef });
|
|
634
|
+
if (!confirmed.ok) throw new Error(`hold not confirmed: ${confirmed.code}`);
|
|
635
|
+
|
|
636
|
+
// A booking your own system already has, by its reference there: revision 1 creates it.
|
|
637
|
+
const imported = await tenant.createExternalReservation({
|
|
638
|
+
tenantRef,
|
|
639
|
+
externalRef: 'BK-43',
|
|
640
|
+
resourceId,
|
|
641
|
+
start: new Date('2027-04-05T11:00:00Z'),
|
|
642
|
+
end: new Date('2027-04-05T12:00:00Z'),
|
|
643
|
+
bufferAfterMs: 15 * 60_000,
|
|
644
|
+
revision: 1,
|
|
645
|
+
});
|
|
646
|
+
if (!imported.ok) throw new Error(`BK-43 not booked: ${imported.code}`);
|
|
647
|
+
return { booking: confirmed.reservation, imported: imported.reservation };
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// What changed, oldest first, once committed: so in a withTenant of its own. Poll again with
|
|
651
|
+
// `after: feed.next`.
|
|
652
|
+
const feed = await store.withTenant(tenantRef, (tenant) =>
|
|
653
|
+
tenant.listChanges({ tenantRef, limit: 100 }),
|
|
654
|
+
);
|
|
655
|
+
if (!feed.ok) throw new Error(`feed not read: ${feed.code}`);
|
|
656
|
+
return { ...booked, feed };
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
651
660
|
### Recurring events
|
|
652
661
|
|
|
653
662
|
<!-- example: examples/store.ts#recurring -->
|
|
@@ -994,7 +1003,7 @@ A refusal is a result the model can act on:
|
|
|
994
1003
|
|
|
995
1004
|
| Code | Meaning |
|
|
996
1005
|
| --- | --- |
|
|
997
|
-
| `invalid_arguments` | Input failed the schema, or a window is inverted or longer than 367 days |
|
|
1006
|
+
| `invalid_arguments` | Input failed the schema, or a window is inverted or longer than 367 days. A schema failure adds `issues`, up to 20 `{"path":[…],"message":"…"}`, each naming a field by its path in the arguments (`["rules",0,"start_minutes"]`; `[]` is the arguments as a whole): in `error` on MCP and A2A, beside `code` in REST's problem details |
|
|
998
1007
|
| `forbidden` | `authorize` refused the call |
|
|
999
1008
|
| `rate_limited` | `consumeRateLimit` refused the call |
|
|
1000
1009
|
| `not_found`, `resource_not_found`, `event_not_found` | Not visible to this caller, or no such id: a resource id is the `id` `slotlock_list_resources` returns, not its `external_ref` |
|
|
@@ -1186,8 +1195,8 @@ waits for a confirmation.
|
|
|
1186
1195
|
| `POST /v1/resources/{resource_id}/archive` | Take it out of use; `…/restore` puts it back |
|
|
1187
1196
|
| `DELETE /v1/resources/{resource_id}` | Delete it with its history (`409 resource_in_use` while anything on it is live) |
|
|
1188
1197
|
| `GET /v1/resources/{resource_id}/busy?start=…&end=…` | Its occupied time, private resources included |
|
|
1189
|
-
| `PUT /v1/resources/{resource_id}/availability` |
|
|
1190
|
-
| `PUT /v1/availability` |
|
|
1198
|
+
| `PUT /v1/resources/{resource_id}/availability` | `{"rules":[…]}`: its own weekly hours; `"rules":[]` never bookable, `"rules":null` the server's |
|
|
1199
|
+
| `PUT /v1/availability` | `{"rules":[…]}`: every resource's weekly hours at once |
|
|
1191
1200
|
| `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
|
|
1192
1201
|
| `GET /v1/holds/{hold_id}` | One live hold; `GET /v1/resources/{resource_id}/holds` lists them |
|
|
1193
1202
|
| `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
|
|
@@ -1253,9 +1262,12 @@ webhook URLs or secrets.
|
|
|
1253
1262
|
and check-in rules, pool, bookable hours, time zone or reference, with `before` and `after` of
|
|
1254
1263
|
only the fields that changed), `resource.archived`, `.restored`, `.deleted`; `coverage.recorded`
|
|
1255
1264
|
(a source's first read; reading it again is not a change) and `coverage.deleted`. A `coverage.*`
|
|
1256
|
-
change names its source by `source_id` (also its `subject`),
|
|
1257
|
-
|
|
1258
|
-
|
|
1265
|
+
change names its source by `source_id` (also its `subject`), never the source itself, which can
|
|
1266
|
+
be an iCalendar URL with a token or an address: `src_` and 32 random hex digits, made when the
|
|
1267
|
+
source is first recorded for the resource and kept until it is deleted, so it cannot be worked
|
|
1268
|
+
out from the source. `slotlock_get_coverage` (`listCoverage`) gives it beside the source, to
|
|
1269
|
+
match a change to its source. A change about a source deleted before 0.6 named it is
|
|
1270
|
+
`src_redacted`;
|
|
1259
1271
|
`webhook.created`, `.enabled`, `.disabled`, `.deleted` (the id and `event_types`, never the URL);
|
|
1260
1272
|
`key.created`, `.rotated`, `.revoked` (the id, scopes and expiry, never the key, its digest,
|
|
1261
1273
|
prefix or name). `SLOTLOCK_CHANGE_TYPES` lists them.
|
|
@@ -1615,13 +1627,15 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
|
|
|
1615
1627
|
| PostgreSQL | 16, 17 and 18 (CI runs the database suites on each; Compose ships 18) |
|
|
1616
1628
|
| postgres (peer) | ^3.4.5 |
|
|
1617
1629
|
|
|
1630
|
+
0.2.0 and older can double-book around daylight-saving changes: [known issues](https://github.com/PILOTAAI/slotlock/blob/main/docs/release/0.2.0-known-issues.md#the-known-issues).
|
|
1631
|
+
|
|
1618
1632
|
## Project status and boundaries
|
|
1619
1633
|
|
|
1620
1634
|
Slotlock is availability maths, holds and reservations, events with recurrence, coverage-aware
|
|
1621
1635
|
free/busy, tenant isolation, iCalendar interchange, MCP and A2A with human confirmation and live
|
|
1622
1636
|
updates, an MCP Apps view, REST with the application API, scoped API keys, a GitHub sign-in
|
|
1623
|
-
dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md)
|
|
1624
|
-
not open
|
|
1637
|
+
dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md)
|
|
1638
|
+
is not open; self-hosting is the only supported path today. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
|
|
1625
1639
|
calendar app are yours to build around it. What each version promises is in
|
|
1626
1640
|
[VERSIONING.md](VERSIONING.md).
|
|
1627
1641
|
|
package/SECURITY.md
CHANGED
|
@@ -22,19 +22,12 @@ Do not include real customer, credential, calendar, or booking data. We will ack
|
|
|
22
22
|
within three business days and coordinate disclosure after a fix is available. Fixed vulnerabilities
|
|
23
23
|
are published as advisories at <https://github.com/PILOTAAI/slotlock/security/advisories>.
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Hosted Slotlock
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- Test only against an account of your own, signed in with your own GitHub account.
|
|
32
|
-
- Never read, change or keep another account's data. If a test reaches any, stop and report it at
|
|
33
|
-
once, and delete what you saw.
|
|
34
|
-
- Run no denial of service, load test or automated scanning that sends more than the published
|
|
35
|
-
limits ([hosted service page](docs/hosted/README.md#limits)); run Slotlock yourself for that.
|
|
36
|
-
- Report through private vulnerability reporting as above, and give us the time to fix it before
|
|
37
|
-
you disclose anything.
|
|
27
|
+
The hosted service is not open; self-hosting is the only supported path today. Do not test
|
|
28
|
+
`slotlock-api.pylota.io` or `app.slotlock.pylota.io`: they are not in scope until this section
|
|
29
|
+
says the service is open and gives the rules for testing it. Test Slotlock by running it yourself,
|
|
30
|
+
and report what you find as above.
|
|
38
31
|
|
|
39
32
|
## Security model
|
|
40
33
|
|
|
@@ -84,7 +77,10 @@ earlier, `PUBLIC` may still hold `CREATE` on `public`; the check then refuses ev
|
|
|
84
77
|
a lock that blocks other tenants: that is availability, not isolation. `slotlock serve` checks the
|
|
85
78
|
role it connects as before it serves, and refuses to start as a superuser or a `BYPASSRLS` role
|
|
86
79
|
(`unsafe_serving_role`); it does not repeat the rest of this check, so that a single owning role can
|
|
87
|
-
still migrate and serve (`serve --migrate` without `SLOTLOCK_MIGRATE_DATABASE_URL`).
|
|
80
|
+
still migrate and serve (`serve --migrate` without `SLOTLOCK_MIGRATE_DATABASE_URL`). Row-level
|
|
81
|
+
security does not confine the owner on tables that do not force it, and the owner can lift it, so
|
|
82
|
+
`serve` logs a `serving_as_schema_owner` warning at startup when its role owns the `slotlock`
|
|
83
|
+
schema: serve as a role that owns nothing in production.
|
|
88
84
|
|
|
89
85
|
Schema changes run under a separate deployment role, which must own the `slotlock` schema and every
|
|
90
86
|
object in it (`applySchema` creates the schema when it is missing). The deployment writes rows into
|
|
@@ -105,6 +101,23 @@ as the deployment role or a superuser, or trust its owner as much. `SLOTLOCK_COR
|
|
|
105
101
|
caller's `search_path` at their end; apply each in one transaction (`psql --single-transaction`),
|
|
106
102
|
since under autocommit the pin ends with the statement that sets it.
|
|
107
103
|
|
|
104
|
+
The change feed, the history and webhooks name a coverage source by its `source_id`, never the
|
|
105
|
+
source itself, which can be an iCalendar URL with a token or an address. The id is random (`src_` and
|
|
106
|
+
32 hex digits from `gen_random_uuid()`), stored with the source when it is first recorded and kept
|
|
107
|
+
until it is deleted, so someone who sees a change and knows the tenant cannot confirm a guessed
|
|
108
|
+
source against it (0.5.3's id was an unkeyed digest of the tenant and the source, and could be).
|
|
109
|
+
The tenant reads the id beside the source from `listCoverage`. Upgrading renames the coverage
|
|
110
|
+
changes earlier releases wrote, which carried the source, its last error or that digest, and a
|
|
111
|
+
trigger on the outbox puts one an earlier release's server (0.3-0.5) still running writes after the
|
|
112
|
+
upgrade in the same shape as it is inserted, so none is stored with its source. Every read path serves a `coverage.*` change without `source` and `last_error`, named
|
|
113
|
+
`src_redacted` when its subject is not such an id, and history by subject never matches a coverage
|
|
114
|
+
change by any other name. What was already sent stays sent: a webhook receiver keeps an earlier
|
|
115
|
+
delivery, so rotate a token that an earlier release delivered in a `coverage.stale` change. A
|
|
116
|
+
0.5.3 `source_id` already forwarded (a webhook delivery, a feed reader's copy, a log) still reveals
|
|
117
|
+
its source to anyone who guesses the source and knows the tenant, as it did then; the rename changes
|
|
118
|
+
only the copies in the outbox. `slotlockCoverageSourceId`, which worked that digest out, throws
|
|
119
|
+
since 0.6.
|
|
120
|
+
|
|
108
121
|
The privacy-minimised provider parser cannot return summaries, locations, notes, organizers or
|
|
109
122
|
attendees. Full event parsing is a separate trusted API and must only receive authenticated content.
|
|
110
123
|
Event command rows retain payload digests and stable results, never duplicate event content. Event
|
package/agent-server.d.ts
CHANGED
|
@@ -374,6 +374,12 @@ export interface SlotlockAgentOperationDispatchOptions {
|
|
|
374
374
|
/** As `SlotlockAgentServerOptions.onError`: a failure the result reports only as internal. */
|
|
375
375
|
onError?: (error: unknown, context: SlotlockAgentErrorContext) => void;
|
|
376
376
|
}
|
|
377
|
+
/** One reason `invalid_arguments` refused a call: where in the arguments, and what is wrong there. */
|
|
378
|
+
export interface SlotlockArgumentIssue {
|
|
379
|
+
/** The field's path in the arguments, outermost first (`["rules", 0, "rrule"]`); `[]` is the arguments as a whole. */
|
|
380
|
+
path: (string | number)[];
|
|
381
|
+
message: string;
|
|
382
|
+
}
|
|
377
383
|
export type SlotlockAgentOperationDispatchResult = {
|
|
378
384
|
ok: true;
|
|
379
385
|
data: Record<string, unknown>;
|
|
@@ -382,6 +388,8 @@ export type SlotlockAgentOperationDispatchResult = {
|
|
|
382
388
|
status: number;
|
|
383
389
|
code: string;
|
|
384
390
|
details?: Readonly<Record<string, string>>;
|
|
391
|
+
/** With `invalid_arguments` from argument validation: the fields it refused. */
|
|
392
|
+
issues?: readonly SlotlockArgumentIssue[];
|
|
385
393
|
};
|
|
386
394
|
/** Runtime type guard for the current operation names (legacy dotted names are not included). */
|
|
387
395
|
export declare function isSlotlockAgentOperation(name: unknown): name is SlotlockAgentOperation;
|
|
@@ -419,6 +427,7 @@ export declare function slotlockMcpToolResult(outcome: {
|
|
|
419
427
|
ok: false;
|
|
420
428
|
code: string;
|
|
421
429
|
details?: Readonly<Record<string, string | number | boolean | null>>;
|
|
430
|
+
issues?: readonly SlotlockArgumentIssue[];
|
|
422
431
|
}): SlotlockMcpToolResult;
|
|
423
432
|
/** The application operations with their input and output as JSON Schema, as REST serves them. */
|
|
424
433
|
export declare function slotlockApplicationOperations(): {
|
package/agent-server.js
CHANGED
|
@@ -380,6 +380,21 @@ export class SlotlockAgentOperationError extends Error {
|
|
|
380
380
|
this.details = Object.freeze(Object.fromEntries(kept));
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
|
+
/** At most this many issues are returned, each message cut to {@link MAX_ISSUE_MESSAGE} characters. */
|
|
384
|
+
const MAX_ISSUES = 20;
|
|
385
|
+
const MAX_ISSUE_MESSAGE = 200;
|
|
386
|
+
/** Argument validation's refusal: 400 `invalid_arguments` with the fields zod refused. */
|
|
387
|
+
function invalidArguments(error) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
status: 400,
|
|
391
|
+
code: 'invalid_arguments',
|
|
392
|
+
issues: error.issues.slice(0, MAX_ISSUES).map((issue) => ({
|
|
393
|
+
path: issue.path.map((key) => (typeof key === 'number' ? key : String(key))),
|
|
394
|
+
message: issue.message.slice(0, MAX_ISSUE_MESSAGE),
|
|
395
|
+
})),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
383
398
|
const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
|
|
384
399
|
const EXAMPLE_EVENT_ID = 'agent:5e8ff9bf55ba3508199d22e984129be6b5b6a8a7e3e5f1f0d2b9ba0c4a6f7e21';
|
|
385
400
|
const EXAMPLE_WINDOW = { start: '2027-03-01T00:00:00Z', end: '2027-03-08T00:00:00Z' };
|
|
@@ -845,6 +860,8 @@ export function slotlockMcpToolResult(outcome) {
|
|
|
845
860
|
if (key !== 'code')
|
|
846
861
|
error[key] = value;
|
|
847
862
|
}
|
|
863
|
+
if (outcome.issues)
|
|
864
|
+
error.issues = outcome.issues;
|
|
848
865
|
return { content: [{ type: 'text', text: JSON.stringify({ error }) }], isError: true };
|
|
849
866
|
}
|
|
850
867
|
/** The agent tools as REST serves them: each with the scope it needs. */
|
|
@@ -1152,7 +1169,7 @@ export async function invokeSlotlockAgentOperation(args) {
|
|
|
1152
1169
|
}
|
|
1153
1170
|
const parsed = operation.input.safeParse(args.input ?? {});
|
|
1154
1171
|
if (!parsed.success)
|
|
1155
|
-
return
|
|
1172
|
+
return invalidArguments(parsed.error);
|
|
1156
1173
|
if (!(await args.options.authorize({
|
|
1157
1174
|
principal,
|
|
1158
1175
|
operation: operation.name,
|
|
@@ -3145,7 +3162,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3145
3162
|
}
|
|
3146
3163
|
const parsed = definition.input.safeParse(given);
|
|
3147
3164
|
if (!parsed.success)
|
|
3148
|
-
return
|
|
3165
|
+
return invalidArguments(parsed.error);
|
|
3149
3166
|
if (!(await served.authorize({ principal, operation: definition.name, input: parsed.data }))) {
|
|
3150
3167
|
return { ok: false, status: 403, code: 'forbidden' };
|
|
3151
3168
|
}
|
|
@@ -3309,6 +3326,7 @@ export function createSlotlockAgentServer(options) {
|
|
|
3309
3326
|
return refuse(status, outcome.code, {}, {
|
|
3310
3327
|
...(outcome.code === 'overlap' ? refusedInterval(given) : {}),
|
|
3311
3328
|
...outcome.details,
|
|
3329
|
+
...(outcome.issues ? { issues: outcome.issues } : {}),
|
|
3312
3330
|
});
|
|
3313
3331
|
}
|
|
3314
3332
|
return {
|
|
@@ -3685,7 +3703,15 @@ export function createSlotlockAgentServer(options) {
|
|
|
3685
3703
|
if (!outcome.ok && INTERNAL_FAILURES.has(outcome.code)) {
|
|
3686
3704
|
return json(200, rpcError(id, -32603, 'Internal error'));
|
|
3687
3705
|
}
|
|
3688
|
-
return reply(outcome.ok
|
|
3706
|
+
return reply(outcome.ok
|
|
3707
|
+
? outcome.data
|
|
3708
|
+
: {
|
|
3709
|
+
error: {
|
|
3710
|
+
...outcome.details,
|
|
3711
|
+
code: outcome.code,
|
|
3712
|
+
...(outcome.issues ? { issues: outcome.issues } : {}),
|
|
3713
|
+
},
|
|
3714
|
+
});
|
|
3689
3715
|
}
|
|
3690
3716
|
catch {
|
|
3691
3717
|
return json(200, rpcError(id, -32603, 'Internal error'));
|
package/application-api.js
CHANGED
|
@@ -757,7 +757,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
757
757
|
{
|
|
758
758
|
name: 'slotlock_get_coverage',
|
|
759
759
|
title: "A resource's coverage and its health",
|
|
760
|
-
description: "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again. The feed names a source by its source_id, never the source itself.",
|
|
760
|
+
description: "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again. The feed names a source by its source_id (random, made when the source is first recorded for the resource and kept until it is deleted), never the source itself.",
|
|
761
761
|
method: 'GET',
|
|
762
762
|
path: '/v1/resources/{resource_id}/coverage',
|
|
763
763
|
input: z.object({ resource_id: RESOURCE_ID }).strict(),
|
|
@@ -767,8 +767,11 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
|
|
|
767
767
|
.array(z
|
|
768
768
|
.object({
|
|
769
769
|
source: SOURCE,
|
|
770
|
-
/**
|
|
771
|
-
|
|
770
|
+
/**
|
|
771
|
+
* How the change feed names the source (coverage.recorded, .deleted, .stale): random,
|
|
772
|
+
* made when it is first recorded for the resource, kept until it is deleted.
|
|
773
|
+
*/
|
|
774
|
+
source_id: z.string().regex(/^src_[0-9a-f]{32}$/),
|
|
772
775
|
start: DATE_TIME,
|
|
773
776
|
end: DATE_TIME,
|
|
774
777
|
revision: REVISION,
|
package/ddl.d.ts
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
export declare const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = "Slotlock will not deploy into schema slotlock";
|
|
3
3
|
/**
|
|
4
4
|
* The version of the schema this release creates, recorded in `slotlock.schema_version` by every
|
|
5
|
-
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2.
|
|
5
|
+
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2; 0.3 to 0.5
|
|
6
|
+
* recorded 3. 0.6 is 4, so a 0.5 release refuses it before it changes anything: its DDL would put
|
|
7
|
+
* back the trigger that names a coverage source by its guessable digest.
|
|
6
8
|
*/
|
|
7
|
-
export declare const SLOTLOCK_SCHEMA_VERSION =
|
|
9
|
+
export declare const SLOTLOCK_SCHEMA_VERSION = 4;
|
|
8
10
|
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
9
11
|
export declare const SLOTLOCK_SCHEMA_NEWER_REFUSAL = "Slotlock will not migrate a schema a newer release migrated";
|
|
10
12
|
/**
|
|
@@ -12,7 +14,7 @@ export declare const SLOTLOCK_SCHEMA_NEWER_REFUSAL = "Slotlock will not migrate
|
|
|
12
14
|
* when they find nothing to change: that no other role controls the schema, then the server version
|
|
13
15
|
* and the schema version. Run with search_path pinned, as the DDL pins it.
|
|
14
16
|
*/
|
|
15
|
-
export declare const SLOTLOCK_DEPLOYMENT_CHECKS = "DO $slotlock_schema_control$\nDECLARE\n refusals text[];\nBEGIN\n SELECT pg_catalog.array_agg(refusal.reason ORDER BY refusal.reason)\n INTO refusals\n FROM (\n SELECT 'schema slotlock is owned by ' || owned_by.rolname AS reason\n FROM pg_catalog.pg_namespace ns\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ns.nspowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create objects in schema slotlock'\n FROM pg_catalog.pg_namespace ns\n CROSS JOIN LATERAL pg_catalog.aclexplode(ns.nspacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'CREATE'\n AND acl.grantee <> ns.nspowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT ident.type || ' ' || ident.identity || ' is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_shdepend owned\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = owned.refobjid\n CROSS JOIN LATERAL pg_catalog.pg_identify_object(owned.classid, owned.objid, owned.objsubid) ident\n WHERE owned.dbid = (SELECT db.oid FROM pg_catalog.pg_database db\n WHERE db.datname = pg_catalog.current_database())\n AND owned.deptype = 'o'\n AND owned.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n AND ident.schema = 'slotlock'\n UNION\n SELECT 'trigger ' || trg.tgname || ' on slotlock.' || rel.relname || ' runs '\n || trg.tgfoid::pg_catalog.regprocedure::pg_catalog.text\n || ', owned by ' || owned_by.rolname\n FROM pg_catalog.pg_trigger trg\n JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n JOIN pg_catalog.pg_proc fn ON fn.oid = trg.tgfoid\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = fn.proowner\n WHERE ns.nspname = 'slotlock'\n AND NOT trg.tgisinternal\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create triggers on slotlock.'\n || rel.relname\n FROM pg_catalog.pg_class rel\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n CROSS JOIN LATERAL pg_catalog.aclexplode(rel.relacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'TRIGGER'\n AND acl.grantee <> rel.relowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT 'extension ' || ext.extname || ' in schema slotlock is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_extension ext\n JOIN pg_catalog.pg_namespace ns ON ns.oid = ext.extnamespace\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ext.extowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n ) refusal;\n IF refusals IS NOT NULL THEN\n RAISE EXCEPTION 'Slotlock will not deploy into schema slotlock: another role controls it'\n USING ERRCODE = 'insufficient_privilege',\n DETAIL = pg_catalog.to_jsonb(refusals)::pg_catalog.text,\n HINT = 'The deployment role must own schema slotlock and every object in it, and no other role may create objects in it.';\n END IF;\nEND\n$slotlock_schema_control$;\nDO $slotlock_schema_preconditions$\nDECLARE\n installed integer;\nBEGIN\n IF pg_catalog.current_setting('server_version_num')::integer < 160000 THEN\n RAISE EXCEPTION 'Slotlock needs PostgreSQL 16 or later; this server is %',\n pg_catalog.current_setting('server_version')\n USING ERRCODE = 'feature_not_supported';\n END IF;\n IF pg_catalog.to_regclass('slotlock.schema_version') IS NOT NULL THEN\n EXECUTE 'SELECT version FROM slotlock.schema_version WHERE singleton' INTO installed;\n IF installed >
|
|
17
|
+
export declare const SLOTLOCK_DEPLOYMENT_CHECKS = "DO $slotlock_schema_control$\nDECLARE\n refusals text[];\nBEGIN\n SELECT pg_catalog.array_agg(refusal.reason ORDER BY refusal.reason)\n INTO refusals\n FROM (\n SELECT 'schema slotlock is owned by ' || owned_by.rolname AS reason\n FROM pg_catalog.pg_namespace ns\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ns.nspowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create objects in schema slotlock'\n FROM pg_catalog.pg_namespace ns\n CROSS JOIN LATERAL pg_catalog.aclexplode(ns.nspacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'CREATE'\n AND acl.grantee <> ns.nspowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT ident.type || ' ' || ident.identity || ' is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_shdepend owned\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = owned.refobjid\n CROSS JOIN LATERAL pg_catalog.pg_identify_object(owned.classid, owned.objid, owned.objsubid) ident\n WHERE owned.dbid = (SELECT db.oid FROM pg_catalog.pg_database db\n WHERE db.datname = pg_catalog.current_database())\n AND owned.deptype = 'o'\n AND owned.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n AND ident.schema = 'slotlock'\n UNION\n SELECT 'trigger ' || trg.tgname || ' on slotlock.' || rel.relname || ' runs '\n || trg.tgfoid::pg_catalog.regprocedure::pg_catalog.text\n || ', owned by ' || owned_by.rolname\n FROM pg_catalog.pg_trigger trg\n JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n JOIN pg_catalog.pg_proc fn ON fn.oid = trg.tgfoid\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = fn.proowner\n WHERE ns.nspname = 'slotlock'\n AND NOT trg.tgisinternal\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n UNION\n SELECT COALESCE(grantee_role.rolname, 'PUBLIC') || ' can create triggers on slotlock.'\n || rel.relname\n FROM pg_catalog.pg_class rel\n JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace\n CROSS JOIN LATERAL pg_catalog.aclexplode(rel.relacl) acl\n LEFT JOIN pg_catalog.pg_roles grantee_role ON grantee_role.oid = acl.grantee\n WHERE ns.nspname = 'slotlock'\n AND acl.privilege_type = 'TRIGGER'\n AND acl.grantee <> rel.relowner\n AND (acl.grantee = 0\n OR (grantee_role.rolname <> CURRENT_USER AND NOT grantee_role.rolsuper))\n UNION\n SELECT 'extension ' || ext.extname || ' in schema slotlock is owned by ' || owned_by.rolname\n FROM pg_catalog.pg_extension ext\n JOIN pg_catalog.pg_namespace ns ON ns.oid = ext.extnamespace\n JOIN pg_catalog.pg_roles owned_by ON owned_by.oid = ext.extowner\n WHERE ns.nspname = 'slotlock'\n AND owned_by.rolname <> CURRENT_USER AND NOT owned_by.rolsuper\n ) refusal;\n IF refusals IS NOT NULL THEN\n RAISE EXCEPTION 'Slotlock will not deploy into schema slotlock: another role controls it'\n USING ERRCODE = 'insufficient_privilege',\n DETAIL = pg_catalog.to_jsonb(refusals)::pg_catalog.text,\n HINT = 'The deployment role must own schema slotlock and every object in it, and no other role may create objects in it.';\n END IF;\nEND\n$slotlock_schema_control$;\nDO $slotlock_schema_preconditions$\nDECLARE\n installed integer;\nBEGIN\n IF pg_catalog.current_setting('server_version_num')::integer < 160000 THEN\n RAISE EXCEPTION 'Slotlock needs PostgreSQL 16 or later; this server is %',\n pg_catalog.current_setting('server_version')\n USING ERRCODE = 'feature_not_supported';\n END IF;\n IF pg_catalog.to_regclass('slotlock.schema_version') IS NOT NULL THEN\n EXECUTE 'SELECT version FROM slotlock.schema_version WHERE singleton' INTO installed;\n IF installed > 4 THEN\n RAISE EXCEPTION 'Slotlock will not migrate a schema a newer release migrated (version %; this release is version 4)', installed\n USING ERRCODE = 'object_not_in_prerequisite_state',\n HINT = 'Migrate and serve it with the release that migrated it, or a later one.';\n END IF;\n END IF;\nEND\n$slotlock_schema_preconditions$;\n";
|
|
16
18
|
/** Active (unrevoked, unexpired) API keys one tenant may hold. */
|
|
17
19
|
export declare const SLOTLOCK_API_KEY_ACTIVE_LIMIT = 100;
|
|
18
20
|
/** API keys one tenant may hold in all, revoked and expired ones included, so lists stay bounded. */
|
|
@@ -63,6 +65,21 @@ export declare const SLOTLOCK_DEFINER_TABLES: readonly ("api_key_retired_digests
|
|
|
63
65
|
export declare function slotlockTenantLockKey(tenantRef: string): string;
|
|
64
66
|
/** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
|
|
65
67
|
export declare const SLOTLOCK_VALIDATION_DDL: string;
|
|
68
|
+
/** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
|
|
69
|
+
export declare const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5000;
|
|
70
|
+
/**
|
|
71
|
+
* One batch of the rename of coverage changes an earlier release wrote: of the outbox rows with ids
|
|
72
|
+
* after `after` up to `head` (SQL expressions, bigint), the first `batch` by id, each `coverage.*`
|
|
73
|
+
* one not yet in this release's shape gets its source's source_id as subject and `data.source_id`,
|
|
74
|
+
* and loses `source` and `last_error`. Before 0.5.3 coverage.stale carried the source itself (an
|
|
75
|
+
* iCalendar URL with a token, an address) and the error read from it; 0.5.3 named it by an unkeyed
|
|
76
|
+
* digest anyone could confirm a guess against. A source deleted since is `src_redacted`: nothing
|
|
77
|
+
* can match it any more, and a made-up id would look like a source the tenant could find. Selects
|
|
78
|
+
* the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
|
|
79
|
+
* `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
|
|
80
|
+
* nothing.
|
|
81
|
+
*/
|
|
82
|
+
export declare function slotlockCoverageChangeRenameSql(after: string, head: string, batch: string, into?: string): string;
|
|
66
83
|
/** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
|
|
67
84
|
export declare const SLOTLOCK_CORE_DDL: string;
|
|
68
85
|
/**
|