slotlock 0.6.2 → 0.7.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 +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/event-feed.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// The event feed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
// The event feed: what changed for a tenant, read by position, and the webhook endpoints it is
|
|
2
|
+
// delivered to. Rows are appended by triggers in the changing transaction (ddl.ts,
|
|
3
|
+
// SLOTLOCK_OUTBOX_DDL); webhooks.ts signs and sends.
|
|
4
|
+
import { slotlockPlanReadRefusal } from './plans.js';
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
6
|
/** A transaction on a client, or a savepoint inside the caller's (a tenant's, say). */
|
|
7
7
|
async function inTransaction(sql, callback) {
|
|
8
8
|
return ('begin' in sql ? sql.begin(callback) : sql.savepoint(callback));
|
|
@@ -27,9 +27,15 @@ export const SLOTLOCK_CHANGE_TYPES = Object.freeze([
|
|
|
27
27
|
'coverage.stale',
|
|
28
28
|
'waitlist.offered',
|
|
29
29
|
'waitlist.expired',
|
|
30
|
+
// A write waiting for a person (approval policies): requested, then granted, declined or expired.
|
|
31
|
+
'approval.requested',
|
|
32
|
+
'approval.granted',
|
|
33
|
+
'approval.declined',
|
|
34
|
+
'approval.expired',
|
|
30
35
|
'webhook.disabled',
|
|
31
|
-
//
|
|
32
|
-
|
|
36
|
+
// A plan's monthly quota 80% or 100% used (quotas.ts): the metric, the share and the counts.
|
|
37
|
+
'usage.threshold_reached',
|
|
38
|
+
// Configuration changes: ids and states, never a secret or a URL.
|
|
33
39
|
'resource.created',
|
|
34
40
|
'resource.updated',
|
|
35
41
|
'resource.archived',
|
|
@@ -45,10 +51,9 @@ export const SLOTLOCK_CHANGE_TYPES = Object.freeze([
|
|
|
45
51
|
'key.revoked',
|
|
46
52
|
]);
|
|
47
53
|
/**
|
|
48
|
-
* Where a change came from
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* store itself (`library`). Never an address or anything else about a person.
|
|
54
|
+
* Where a change came from (withTenant's `origin`): an agent protocol (`mcp`, `a2a`), the REST
|
|
55
|
+
* API's agent routes (`rest`) or application operations (`application`), the dashboard, its
|
|
56
|
+
* assistant, maintenance, or direct store calls (`library`). Never anything about a person.
|
|
52
57
|
*/
|
|
53
58
|
export const SLOTLOCK_CHANGE_ORIGINS = Object.freeze([
|
|
54
59
|
'mcp',
|
|
@@ -65,20 +70,16 @@ export function isSlotlockChangeOrigin(value) {
|
|
|
65
70
|
return typeof value === 'string' && SLOTLOCK_CHANGE_ORIGINS.includes(value);
|
|
66
71
|
}
|
|
67
72
|
/**
|
|
68
|
-
* The digest 0.5.3 named a coverage source by
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* the upgrade uses this only to find the changes 0.5.3 wrote and rename them. `tenant` and `source`
|
|
72
|
-
* are SQL expressions.
|
|
73
|
+
* The unkeyed digest 0.5.3 named a coverage source by (`src_` + 24 hex of sha256(tenant, newline,
|
|
74
|
+
* source)); anyone knowing the tenant could confirm a guess. Used only by the upgrade to find and
|
|
75
|
+
* rename those changes. `tenant` and `source` are SQL expressions.
|
|
73
76
|
*/
|
|
74
77
|
export function legacyCoverageSourceDigestSql(tenant, source) {
|
|
75
78
|
return `'src_' || pg_catalog.left(pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(${tenant} || E'\\n' || ${source}, 'UTF8')), 'hex'), 24)`;
|
|
76
79
|
}
|
|
77
80
|
/**
|
|
78
|
-
* 0.
|
|
79
|
-
*
|
|
80
|
-
* only the database knows it, so this throws (`coverage_source_id_unavailable`) rather than give a
|
|
81
|
-
* digest nothing carries any more.
|
|
81
|
+
* Throws `coverage_source_id_unavailable`: since 0.6 a source's `source_id` is random and only the
|
|
82
|
+
* database knows it (0.5.3's unkeyed digest let anyone confirm a guessed source).
|
|
82
83
|
*
|
|
83
84
|
* @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
|
|
84
85
|
* be removed in 0.7.0.
|
|
@@ -89,17 +90,15 @@ export function slotlockCoverageSourceId(_tenantRef, _source) {
|
|
|
89
90
|
/** What a coverage source's `source_id` is: `src_` and 32 random hex digits. */
|
|
90
91
|
const SLOTLOCK_COVERAGE_SOURCE_ID = /^src_[0-9a-f]{32}$/;
|
|
91
92
|
/**
|
|
92
|
-
* The `source_id` of a `coverage.*` change whose source
|
|
93
|
-
*
|
|
94
|
-
* deleted, or one not yet renamed when it is read.
|
|
93
|
+
* The `source_id` of a `coverage.*` change whose source can no longer be named: an earlier
|
|
94
|
+
* release's change whose source was deleted, or one not yet renamed.
|
|
95
95
|
*/
|
|
96
96
|
const SLOTLOCK_REDACTED_SOURCE_ID = 'src_redacted';
|
|
97
97
|
/**
|
|
98
|
-
* A change's subject and data as the feed serves them. A `coverage.*` change never carries
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* earlier release's server writes during a rolling upgrade, is served redacted.
|
|
98
|
+
* A change's subject and data as the feed serves them. A `coverage.*` change never carries `source`
|
|
99
|
+
* or `last_error` (which can quote it), and is named by a current `source_id` or `src_redacted`:
|
|
100
|
+
* rows an earlier release wrote are renamed in batches after upgrade, so one not reached yet is
|
|
101
|
+
* served redacted.
|
|
103
102
|
*/
|
|
104
103
|
export function slotlockServedChange(type, subject, data) {
|
|
105
104
|
if (!type.startsWith('coverage.'))
|
|
@@ -111,9 +110,13 @@ export function slotlockServedChange(type, subject, data) {
|
|
|
111
110
|
/** How long the feed keeps a change, and so how far back a cursor may reach. */
|
|
112
111
|
export const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
|
|
113
112
|
/** Webhook endpoints one tenant may have. */
|
|
114
|
-
export const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS =
|
|
113
|
+
export const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 25;
|
|
115
114
|
/** After this long failing, an endpoint is disabled and the feed says so (`webhook.disabled`). */
|
|
116
115
|
export const SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS = 24 * 60 * 60 * 1_000;
|
|
116
|
+
/** Resources one webhook endpoint's filter may name. */
|
|
117
|
+
export const SLOTLOCK_WEBHOOK_FILTER_RESOURCES = 100;
|
|
118
|
+
/** Pools, locations or groups one webhook endpoint's filter may name, of each. */
|
|
119
|
+
export const SLOTLOCK_WEBHOOK_FILTER_REFS = 20;
|
|
117
120
|
function encodePosition(position) {
|
|
118
121
|
return Buffer.from(`2:${position.txid}:${position.id}:${position.at}`).toString('base64url');
|
|
119
122
|
}
|
|
@@ -136,14 +139,14 @@ function toChange(row) {
|
|
|
136
139
|
...slotlockServedChange(row.type, row.subject, row.data),
|
|
137
140
|
occurredAt: row.occurred_at,
|
|
138
141
|
actor: row.actor,
|
|
139
|
-
//
|
|
142
|
+
// An origin a later release added: unrecorded here, not a failed page.
|
|
140
143
|
origin: isSlotlockChangeOrigin(row.origin) ? row.origin : null,
|
|
141
144
|
keyId: row.key_id,
|
|
142
145
|
position: encodePosition({ txid: row.txid, id: row.id, at: row.occurred_at.getTime() }),
|
|
143
146
|
};
|
|
144
147
|
}
|
|
145
148
|
function toEndpoint(row) {
|
|
146
|
-
|
|
149
|
+
const endpoint = {
|
|
147
150
|
id: row.id,
|
|
148
151
|
url: row.url,
|
|
149
152
|
eventTypes: row.event_types,
|
|
@@ -155,9 +158,85 @@ function toEndpoint(row) {
|
|
|
155
158
|
disabledAt: row.disabled_at,
|
|
156
159
|
disabledReason: row.disabled_reason,
|
|
157
160
|
};
|
|
161
|
+
const filter = {};
|
|
162
|
+
if (row.filter_resource_ids)
|
|
163
|
+
filter.resourceIds = row.filter_resource_ids;
|
|
164
|
+
if (row.filter_pools)
|
|
165
|
+
filter.pools = row.filter_pools;
|
|
166
|
+
if (row.filter_locations)
|
|
167
|
+
filter.locations = row.filter_locations;
|
|
168
|
+
if (row.filter_groups)
|
|
169
|
+
filter.groups = row.filter_groups;
|
|
170
|
+
if (Object.keys(filter).length > 0)
|
|
171
|
+
endpoint.filter = filter;
|
|
172
|
+
return endpoint;
|
|
158
173
|
}
|
|
159
174
|
const ENDPOINT_COLUMNS = (sql) => sql `id, url, event_types, created_at, attempts, failing_since, last_status, last_error,
|
|
160
|
-
disabled_at, disabled_reason
|
|
175
|
+
disabled_at, disabled_reason, filter_resource_ids::text[], filter_pools, filter_locations,
|
|
176
|
+
filter_groups`;
|
|
177
|
+
/** A pool's, location's or group's reference. */
|
|
178
|
+
const GROUPING_REF = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
179
|
+
/**
|
|
180
|
+
* A webhook filter as it is stored, each list without repeats, or null when one is out of bounds.
|
|
181
|
+
* An empty filter, or empty lists, filter nothing.
|
|
182
|
+
*/
|
|
183
|
+
function parseWebhookFilter(filter) {
|
|
184
|
+
const list = (values, max, valid) => {
|
|
185
|
+
if (values === undefined)
|
|
186
|
+
return null;
|
|
187
|
+
if (!Array.isArray(values))
|
|
188
|
+
return false;
|
|
189
|
+
const kept = [...new Set(values)];
|
|
190
|
+
if (kept.length === 0)
|
|
191
|
+
return null;
|
|
192
|
+
if (kept.length > max || !kept.every((value) => typeof value === 'string' && valid(value)))
|
|
193
|
+
return false;
|
|
194
|
+
return kept.sort();
|
|
195
|
+
};
|
|
196
|
+
const resourceIds = list(filter?.resourceIds, SLOTLOCK_WEBHOOK_FILTER_RESOURCES, (value) => UUID.test(value));
|
|
197
|
+
const pools = list(filter?.pools, SLOTLOCK_WEBHOOK_FILTER_REFS, (value) => GROUPING_REF.test(value));
|
|
198
|
+
const locations = list(filter?.locations, SLOTLOCK_WEBHOOK_FILTER_REFS, (value) => GROUPING_REF.test(value));
|
|
199
|
+
const groups = list(filter?.groups, SLOTLOCK_WEBHOOK_FILTER_REFS, (value) => GROUPING_REF.test(value));
|
|
200
|
+
if (resourceIds === false || pools === false || locations === false || groups === false)
|
|
201
|
+
return null;
|
|
202
|
+
return {
|
|
203
|
+
resourceIds: resourceIds?.map((id) => id.toLowerCase()) ?? null,
|
|
204
|
+
pools,
|
|
205
|
+
locations,
|
|
206
|
+
groups,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether a change (`change`, a row of slotlock.outbox) passes an endpoint's filter (`due`, a row of
|
|
211
|
+
* slotlock.webhook_endpoints): no filter, or a resource it names, or one in a pool, at a location
|
|
212
|
+
* (or a location in it) or in a group it names, as the resource is now; or a change to a resource
|
|
213
|
+
* that says it was in one (resource.created, resource.deleted, or resource.updated's `before`),
|
|
214
|
+
* so an endpoint also learns of a resource deleted, or moved out of what it follows.
|
|
215
|
+
*/
|
|
216
|
+
const FILTER_MATCH = `(
|
|
217
|
+
(due.filter_resource_ids IS NULL AND due.filter_pools IS NULL
|
|
218
|
+
AND due.filter_locations IS NULL AND due.filter_groups IS NULL)
|
|
219
|
+
OR change.resource_id = ANY (due.filter_resource_ids)
|
|
220
|
+
OR EXISTS (
|
|
221
|
+
SELECT 1 FROM slotlock.resources held
|
|
222
|
+
WHERE held.tenant_ref = change.tenant_ref AND held.id = change.resource_id
|
|
223
|
+
AND (held.pool_ref = ANY (due.filter_pools)
|
|
224
|
+
OR (held.location_ref IS NOT NULL
|
|
225
|
+
AND slotlock.location_path(held.tenant_ref, held.location_ref) && due.filter_locations)
|
|
226
|
+
OR EXISTS (SELECT 1 FROM slotlock.resource_group_members member
|
|
227
|
+
WHERE member.tenant_ref = held.tenant_ref AND member.resource_id = held.id
|
|
228
|
+
AND member.group_ref = ANY (due.filter_groups))))
|
|
229
|
+
OR (change.type LIKE 'resource.%' AND (
|
|
230
|
+
(change.data ->> 'pool') = ANY (due.filter_pools)
|
|
231
|
+
OR (change.data #>> '{before,pool}') = ANY (due.filter_pools)
|
|
232
|
+
OR EXISTS (
|
|
233
|
+
SELECT 1 FROM unnest(ARRAY[change.data ->> 'location', change.data #>> '{before,location}']) was(ref)
|
|
234
|
+
WHERE was.ref IS NOT NULL
|
|
235
|
+
AND (was.ref = ANY (due.filter_locations)
|
|
236
|
+
OR slotlock.location_path(change.tenant_ref, was.ref) && due.filter_locations))
|
|
237
|
+
OR (jsonb_typeof(change.data -> 'groups') = 'array' AND (change.data -> 'groups') ?| due.filter_groups)
|
|
238
|
+
OR (jsonb_typeof(change.data #> '{before,groups}') = 'array'
|
|
239
|
+
AND (change.data #> '{before,groups}') ?| due.filter_groups))))`;
|
|
161
240
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
162
241
|
function isIdentity(value) {
|
|
163
242
|
return typeof value === 'string' && Buffer.byteLength(value) >= 1 && Buffer.byteLength(value) <= 500;
|
|
@@ -202,8 +281,7 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
202
281
|
const after = params.after === undefined ? null : decodePosition(params.after);
|
|
203
282
|
if (params.after !== undefined && !after)
|
|
204
283
|
return { ok: false, code: 'invalid_cursor' };
|
|
205
|
-
// One statement, so the pruned position and the page
|
|
206
|
-
// that commits meanwhile is either wholly seen or not at all.
|
|
284
|
+
// One statement, so the pruned position and the page share a snapshot.
|
|
207
285
|
const rows = await sql `
|
|
208
286
|
SELECT mark.txid::text AS pruned_txid, mark.id::text AS pruned_id,
|
|
209
287
|
pg_snapshot_xmin(pg_current_snapshot())::text AS horizon,
|
|
@@ -227,9 +305,8 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
227
305
|
const head = rows[0];
|
|
228
306
|
if (!head)
|
|
229
307
|
throw new Error('slotlock: change page returned no row');
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// mark its changes started at does not cover every one it took.
|
|
308
|
+
// Pruned by any server: reading on could skip a change. A 0.4 cursor also expires by date,
|
|
309
|
+
// since 0.4 pruned by age, not feed order.
|
|
233
310
|
if (after &&
|
|
234
311
|
((head.pruned_txid !== null &&
|
|
235
312
|
(BigInt(head.pruned_txid) > BigInt(after.txid) ||
|
|
@@ -241,8 +318,8 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
241
318
|
}
|
|
242
319
|
const changes = rows.flatMap((row) => (row.id === null ? [] : [toChange(row)]));
|
|
243
320
|
const last = changes.at(-1);
|
|
244
|
-
//
|
|
245
|
-
//
|
|
321
|
+
// Nothing new: the position stays, dated now. Before any change, every one to come is after
|
|
322
|
+
// the oldest running transaction.
|
|
246
323
|
const here = after ?? { txid: head.horizon, id: '0', at: 0 };
|
|
247
324
|
return {
|
|
248
325
|
ok: true,
|
|
@@ -279,26 +356,60 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
279
356
|
!types.every((type) => SLOTLOCK_CHANGE_TYPES.includes(type)))) {
|
|
280
357
|
return { ok: false, code: 'invalid_event_types' };
|
|
281
358
|
}
|
|
359
|
+
const filter = parseWebhookFilter(params.filter);
|
|
360
|
+
if (filter === null)
|
|
361
|
+
return { ok: false, code: 'invalid_webhook_filter' };
|
|
362
|
+
const key = params.idempotencyKey;
|
|
363
|
+
if (key !== undefined && !isIdentity(key))
|
|
364
|
+
return { ok: false, code: 'invalid_identity' };
|
|
365
|
+
const payloadHash = key === undefined
|
|
366
|
+
? null
|
|
367
|
+
: createHash('sha256')
|
|
368
|
+
.update(JSON.stringify([params.url, types === null ? null : [...types].sort(), filter]))
|
|
369
|
+
.digest('hex');
|
|
282
370
|
const secret = `whsec_${randomBytes(32).toString('base64')}`;
|
|
283
371
|
return inTransaction(sql, async (tx) => {
|
|
284
|
-
// One at a time per tenant, so racing creates stop at the limit.
|
|
285
372
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:webhooks:${params.tenantRef}`}, 0))`;
|
|
373
|
+
if (key !== undefined) {
|
|
374
|
+
// Under the tenant's webhook lock: a retry racing its first finds what the first made.
|
|
375
|
+
const [earlier] = await tx `
|
|
376
|
+
SELECT ${ENDPOINT_COLUMNS(tx)}, secret, payload_hash FROM slotlock.webhook_endpoints
|
|
377
|
+
WHERE tenant_ref = ${params.tenantRef} AND idempotency_key = ${key}`;
|
|
378
|
+
if (earlier) {
|
|
379
|
+
if (earlier.payload_hash !== payloadHash)
|
|
380
|
+
return { ok: false, code: 'idempotency_conflict' };
|
|
381
|
+
return { ok: true, endpoint: toEndpoint(earlier), secret: earlier.secret, replayed: true };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
286
384
|
const [count] = await tx `
|
|
287
|
-
SELECT count(*)::int AS count
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
385
|
+
SELECT count(*)::int AS count,
|
|
386
|
+
(SELECT max_webhook_endpoints FROM slotlock.tenant_plan(${params.tenantRef}::text))
|
|
387
|
+
AS planned
|
|
388
|
+
FROM slotlock.webhook_endpoints
|
|
389
|
+
WHERE tenant_ref = ${params.tenantRef}`.catch((error) => {
|
|
390
|
+
throw slotlockPlanReadRefusal(error);
|
|
391
|
+
});
|
|
392
|
+
// The lower of the server's limit and the tenant's plan (plans.ts), when it has one.
|
|
393
|
+
const limit = Math.min(SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, count?.planned ?? SLOTLOCK_MAX_WEBHOOK_ENDPOINTS);
|
|
394
|
+
if ((count?.count ?? 0) >= limit) {
|
|
395
|
+
return { ok: false, code: 'webhook_limit_reached', limit };
|
|
291
396
|
}
|
|
292
|
-
// From now on: every transaction
|
|
397
|
+
// From now on: every transaction not yet ended is at or after this position.
|
|
293
398
|
const [row] = await tx `
|
|
294
|
-
INSERT INTO slotlock.webhook_endpoints (
|
|
399
|
+
INSERT INTO slotlock.webhook_endpoints (
|
|
400
|
+
tenant_ref, url, secret, event_types, cursor_txid,
|
|
401
|
+
filter_resource_ids, filter_pools, filter_locations, filter_groups,
|
|
402
|
+
idempotency_key, payload_hash
|
|
403
|
+
)
|
|
295
404
|
VALUES (${params.tenantRef}, ${params.url}, ${secret}, ${types}::text[],
|
|
296
|
-
pg_snapshot_xmin(pg_current_snapshot())
|
|
405
|
+
pg_snapshot_xmin(pg_current_snapshot()),
|
|
406
|
+
${filter.resourceIds}::uuid[], ${filter.pools}::text[], ${filter.locations}::text[],
|
|
407
|
+
${filter.groups}::text[], ${key ?? null}, ${payloadHash})
|
|
297
408
|
RETURNING ${ENDPOINT_COLUMNS(tx)}`;
|
|
298
409
|
if (!row)
|
|
299
410
|
throw new Error('slotlock: webhook endpoint insert returned no row');
|
|
300
411
|
await tx `SELECT slotlock.register_webhook_tenant(${params.tenantRef}::text)`;
|
|
301
|
-
return { ok: true, endpoint: toEndpoint(row), secret };
|
|
412
|
+
return { ok: true, endpoint: toEndpoint(row), secret, replayed: false };
|
|
302
413
|
});
|
|
303
414
|
},
|
|
304
415
|
async listWebhookEndpoints(params) {
|
|
@@ -323,7 +434,6 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
323
434
|
const [left] = await tx `
|
|
324
435
|
SELECT count(*)::int AS count FROM slotlock.webhook_endpoints
|
|
325
436
|
WHERE tenant_ref = ${params.tenantRef}`;
|
|
326
|
-
// The tenant's last endpoint takes it off the list servers deliver for.
|
|
327
437
|
if ((left?.count ?? 0) === 0) {
|
|
328
438
|
await tx `SELECT slotlock.unregister_webhook_tenant(${params.tenantRef}::text)`;
|
|
329
439
|
}
|
|
@@ -357,6 +467,32 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
357
467
|
if (!(Number.isSafeInteger(params.batch) && params.batch >= 1 && params.batch <= 100)) {
|
|
358
468
|
throw new RangeError('Slotlock webhook batch must be 1-100 changes');
|
|
359
469
|
}
|
|
470
|
+
// An endpoint that takes only some changes (by type or filter) moves past the ones it does
|
|
471
|
+
// not take, up to the last change that has ended, when none before it is for the endpoint:
|
|
472
|
+
// else its cursor would stay where it was made, keeping the whole feed from being pruned and
|
|
473
|
+
// every claim reading it all again. Not one being delivered (leased), nor one waiting to try
|
|
474
|
+
// again (it has a change for it).
|
|
475
|
+
await sql `
|
|
476
|
+
UPDATE slotlock.webhook_endpoints due
|
|
477
|
+
SET cursor_txid = head.txid, cursor_id = head.id
|
|
478
|
+
FROM (SELECT change.txid, change.id FROM slotlock.outbox change
|
|
479
|
+
WHERE change.tenant_ref = ${params.tenantRef}
|
|
480
|
+
AND change.txid < pg_snapshot_xmin(pg_current_snapshot())
|
|
481
|
+
ORDER BY change.txid DESC, change.id DESC
|
|
482
|
+
LIMIT 1) head
|
|
483
|
+
WHERE due.tenant_ref = ${params.tenantRef}
|
|
484
|
+
AND due.disabled_at IS NULL
|
|
485
|
+
AND (due.lease_until IS NULL OR due.lease_until <= now())
|
|
486
|
+
AND (due.event_types IS NOT NULL OR due.filter_resource_ids IS NOT NULL OR due.filter_pools IS NOT NULL
|
|
487
|
+
OR due.filter_locations IS NOT NULL OR due.filter_groups IS NOT NULL)
|
|
488
|
+
AND (head.txid, head.id) > (due.cursor_txid, due.cursor_id)
|
|
489
|
+
AND NOT EXISTS (
|
|
490
|
+
SELECT 1 FROM slotlock.outbox change
|
|
491
|
+
WHERE change.tenant_ref = due.tenant_ref
|
|
492
|
+
AND (change.txid, change.id) > (due.cursor_txid, due.cursor_id)
|
|
493
|
+
AND (change.txid, change.id) <= (head.txid, head.id)
|
|
494
|
+
AND (due.event_types IS NULL OR change.type = ANY (due.event_types))
|
|
495
|
+
AND ${sql.unsafe(FILTER_MATCH)})`;
|
|
360
496
|
const endpoints = await sql `
|
|
361
497
|
UPDATE slotlock.webhook_endpoints endpoint
|
|
362
498
|
SET lease_until = now() + make_interval(secs => ${params.leaseMs / 1_000}),
|
|
@@ -373,6 +509,7 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
373
509
|
AND change.txid < pg_snapshot_xmin(pg_current_snapshot())
|
|
374
510
|
AND (change.txid, change.id) > (due.cursor_txid, due.cursor_id)
|
|
375
511
|
AND (due.event_types IS NULL OR change.type = ANY (due.event_types))
|
|
512
|
+
AND ${sql.unsafe(FILTER_MATCH)}
|
|
376
513
|
)
|
|
377
514
|
FOR UPDATE SKIP LOCKED
|
|
378
515
|
)
|
|
@@ -381,14 +518,16 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
381
518
|
const claims = [];
|
|
382
519
|
for (const endpoint of endpoints) {
|
|
383
520
|
const rows = await sql `
|
|
384
|
-
SELECT id::text, txid::text, type, resource_id, subject,
|
|
385
|
-
|
|
386
|
-
FROM slotlock.outbox
|
|
387
|
-
WHERE
|
|
388
|
-
AND
|
|
389
|
-
AND
|
|
390
|
-
AND (${endpoint.
|
|
391
|
-
|
|
521
|
+
SELECT change.id::text, change.txid::text, change.type, change.resource_id, change.subject,
|
|
522
|
+
change.data, change.occurred_at, change.actor, change.origin, change.key_id::text
|
|
523
|
+
FROM slotlock.outbox change, slotlock.webhook_endpoints due
|
|
524
|
+
WHERE due.id = ${endpoint.id}::uuid
|
|
525
|
+
AND change.tenant_ref = ${params.tenantRef}
|
|
526
|
+
AND change.txid < pg_snapshot_xmin(pg_current_snapshot())
|
|
527
|
+
AND (change.txid, change.id) > (${endpoint.cursor_txid}::xid8, ${endpoint.cursor_id}::bigint)
|
|
528
|
+
AND (${endpoint.event_types}::text[] IS NULL OR change.type = ANY (${endpoint.event_types}::text[]))
|
|
529
|
+
AND ${sql.unsafe(FILTER_MATCH)}
|
|
530
|
+
ORDER BY change.txid, change.id
|
|
392
531
|
LIMIT ${params.batch}`;
|
|
393
532
|
claims.push({
|
|
394
533
|
endpoint: { id: endpoint.id, url: endpoint.url, secret: endpoint.secret, attempts: endpoint.attempts },
|
|
@@ -443,13 +582,12 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
443
582
|
AND lease_token = ${params.leaseToken}::uuid
|
|
444
583
|
RETURNING disabled_at IS NOT NULL AS disabled`;
|
|
445
584
|
if (settled?.disabled) {
|
|
446
|
-
// The tenant hears of it on the feed, which every reader and other endpoint sees.
|
|
447
585
|
await tx `
|
|
448
586
|
INSERT INTO slotlock.outbox (tenant_ref, type, subject, data)
|
|
449
587
|
VALUES (${params.tenantRef}, 'webhook.disabled', ${params.endpointId},
|
|
450
588
|
${tx.json({
|
|
451
|
-
// The id alone: the feed
|
|
452
|
-
//
|
|
589
|
+
// The id alone: the feed reaches other endpoints, and a URL can carry a
|
|
590
|
+
// token.
|
|
453
591
|
id: params.endpointId,
|
|
454
592
|
reason: failure?.gone === true ? 'gone' : 'failing',
|
|
455
593
|
last_status: failure?.status ?? null,
|
|
@@ -493,10 +631,9 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
493
631
|
async pruneChanges(params) {
|
|
494
632
|
if (!isIdentity(params.tenantRef))
|
|
495
633
|
throw identityError();
|
|
496
|
-
// Oldest first in feed order, and only up to the first change kept
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
// on the feed records how far), and a reader starting over reads on past the gap.
|
|
634
|
+
// Oldest first in feed order, and only up to the first change kept (within retention, still
|
|
635
|
+
// owed to an enabled webhook, or later than the oldest running transaction), so every kept
|
|
636
|
+
// change is after every pruned one and a reader starting over reads past the gap.
|
|
500
637
|
const rows = await sql `
|
|
501
638
|
WITH head AS MATERIALIZED (
|
|
502
639
|
SELECT outbox.id, outbox.txid,
|
package/event-schema.js
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
// A calendar event as the event tools
|
|
2
|
-
//
|
|
3
|
-
// events with it, and the application API imports nothing from the agent server at runtime (the
|
|
4
|
-
// agent server imports it, so the reverse would be a cycle).
|
|
1
|
+
// A calendar event as the event tools and the application API return it. Its own module so the
|
|
2
|
+
// application API need not import the agent server at runtime (that would be a cycle).
|
|
5
3
|
import { z } from 'zod';
|
|
6
4
|
const DATE_TIME = z.string().datetime({ offset: true });
|
|
7
5
|
const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
8
6
|
const EVENT_ID = z.string().trim().min(1).max(500);
|
|
9
7
|
const REVISION = z.number().int().positive();
|
|
10
|
-
// Output bounds mirror the
|
|
11
|
-
//
|
|
12
|
-
// serializable without truncating recurrence semantics or calendar identities.
|
|
8
|
+
// Output bounds mirror the store's UTF-8 byte ceilings, which never exceed as many UTF-16 code
|
|
9
|
+
// units, so every stored event serializes untruncated.
|
|
13
10
|
const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
|
|
14
11
|
const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
|
|
15
12
|
const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
|
|
16
13
|
const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
|
|
17
14
|
const TRUSTED_REMINDER_MINUTES_OUTPUT_MAX = 366 * 24 * 60;
|
|
18
|
-
// The grammar the store keeps an address in (
|
|
19
|
-
//
|
|
15
|
+
// The grammar the store keeps an address in (sync.ts normalizeEmail): an imported address need not
|
|
16
|
+
// pass z.email() (ops@localhost).
|
|
20
17
|
const STORED_ADDRESS = z
|
|
21
18
|
.string()
|
|
22
19
|
.min(3)
|
package/hooks.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Let a hook's promise fail unseen
|
|
3
|
-
*
|
|
4
|
-
* the process on one by default. A hook that throws is caught where it is called; this is the same
|
|
5
|
-
* for one that rejects.
|
|
2
|
+
* Let a hook's promise fail unseen: an async `onEvent` or `onError` returns a promise whose
|
|
3
|
+
* rejection would be unhandled, which ends a Node process by default.
|
|
6
4
|
*/
|
|
7
5
|
export declare function ignoreRejection(result: unknown): void;
|
package/hooks.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Let a hook's promise fail unseen
|
|
3
|
-
*
|
|
4
|
-
* the process on one by default. A hook that throws is caught where it is called; this is the same
|
|
5
|
-
* for one that rejects.
|
|
2
|
+
* Let a hook's promise fail unseen: an async `onEvent` or `onError` returns a promise whose
|
|
3
|
+
* rejection would be unhandled, which ends a Node process by default.
|
|
6
4
|
*/
|
|
7
5
|
export function ignoreRejection(result) {
|
|
8
6
|
if ((typeof result === 'object' || typeof result === 'function') &&
|
package/ical-worker-thread.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// One worker of createICalendarWorkerPool:
|
|
2
|
-
//
|
|
1
|
+
// One worker of createICalendarWorkerPool: runs the ordinary parsers and posts back their result or
|
|
2
|
+
// CalendarContractError code.
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
4
|
import { CalendarContractError, CalendarExpansionLimitError, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, } from './sync.js';
|
|
5
5
|
const port = parentPort;
|
|
@@ -16,8 +16,7 @@ port.on('message', (request) => {
|
|
|
16
16
|
reply = { id: request.id, ok: true, value };
|
|
17
17
|
}
|
|
18
18
|
catch (error) {
|
|
19
|
-
//
|
|
20
|
-
// ends this worker so the pool reports it.
|
|
19
|
+
// Anything but a CalendarContractError is a fault: end this worker so the pool reports it.
|
|
21
20
|
if (!(error instanceof CalendarContractError))
|
|
22
21
|
throw error;
|
|
23
22
|
reply = {
|
package/ical-worker.d.ts
CHANGED
|
@@ -28,9 +28,9 @@ export interface ICalendarWorkerPoolOptions {
|
|
|
28
28
|
/** Workers that may run at once, 1-64. Defaults to 2. */
|
|
29
29
|
size?: number;
|
|
30
30
|
/**
|
|
31
|
-
* How long one parse may run
|
|
32
|
-
* refused (CalendarExpansionLimitError)
|
|
33
|
-
*
|
|
31
|
+
* How long one parse may run, in ms (100-60,000), before its worker is stopped and the feed
|
|
32
|
+
* refused (CalendarExpansionLimitError). Defaults to 3,000: several times the parser's step
|
|
33
|
+
* budget, with room for a worker to start.
|
|
34
34
|
*/
|
|
35
35
|
deadlineMs?: number;
|
|
36
36
|
/** Parses that may wait for a worker, 0-10,000; more are refused at once. Defaults to 64. */
|
|
@@ -38,8 +38,8 @@ export interface ICalendarWorkerPoolOptions {
|
|
|
38
38
|
/** The heap one worker may use, in MB, 16-4,096. Defaults to 128. */
|
|
39
39
|
maxHeapMb?: number;
|
|
40
40
|
/**
|
|
41
|
-
* The module each worker runs. Defaults to `ical-worker-thread.js` beside this module
|
|
42
|
-
*
|
|
41
|
+
* The module each worker runs. Defaults to the built `ical-worker-thread.js` beside this module;
|
|
42
|
+
* Slotlock's TypeScript sources cannot start it (tests pass a stand-in).
|
|
43
43
|
*/
|
|
44
44
|
entry?: URL;
|
|
45
45
|
}
|
|
@@ -64,11 +64,9 @@ export declare class ICalendarWorkerPoolClosedError extends Error {
|
|
|
64
64
|
constructor();
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
|
-
* A pool of worker threads
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* `CalendarContractError` codes), plus CalendarExpansionLimitError when a worker passes its
|
|
71
|
-
* deadline or its heap, ICalendarWorkerBusyError when the queue is full, and
|
|
67
|
+
* A pool of worker threads reading untrusted iCalendar for the calling thread. Results and errors
|
|
68
|
+
* are the parsers' (same `CalendarContractError` codes), plus CalendarExpansionLimitError past a
|
|
69
|
+
* worker's deadline or heap, ICalendarWorkerBusyError when the queue is full, and
|
|
72
70
|
* ICalendarWorkerPoolClosedError after `close()`. Idle workers do not keep the process alive.
|
|
73
71
|
*/
|
|
74
72
|
export declare function createICalendarWorkerPool(options?: ICalendarWorkerPoolOptions): ICalendarWorkerPool;
|
package/ical-worker.js
CHANGED
|
@@ -30,11 +30,9 @@ function workerFailure(error) {
|
|
|
30
30
|
return new Error('slotlock: an iCalendar worker failed', { cause: error });
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
* A pool of worker threads
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* `CalendarContractError` codes), plus CalendarExpansionLimitError when a worker passes its
|
|
37
|
-
* deadline or its heap, ICalendarWorkerBusyError when the queue is full, and
|
|
33
|
+
* A pool of worker threads reading untrusted iCalendar for the calling thread. Results and errors
|
|
34
|
+
* are the parsers' (same `CalendarContractError` codes), plus CalendarExpansionLimitError past a
|
|
35
|
+
* worker's deadline or heap, ICalendarWorkerBusyError when the queue is full, and
|
|
38
36
|
* ICalendarWorkerPoolClosedError after `close()`. Idle workers do not keep the process alive.
|
|
39
37
|
*/
|
|
40
38
|
export function createICalendarWorkerPool(options = {}) {
|
|
@@ -100,8 +98,8 @@ export function createICalendarWorkerPool(options = {}) {
|
|
|
100
98
|
nextId += 1;
|
|
101
99
|
slot.id = nextId;
|
|
102
100
|
slot.worker.ref();
|
|
103
|
-
// Past its deadline the feed is refused and
|
|
104
|
-
//
|
|
101
|
+
// Past its deadline the feed is refused and terminate() ends the worker, whatever ical.js is
|
|
102
|
+
// doing.
|
|
105
103
|
slot.timer = setTimeout(() => retire(slot, new CalendarExpansionLimitError()), deadlineMs);
|
|
106
104
|
slot.worker.postMessage({ ...job.request, id: slot.id });
|
|
107
105
|
}
|
|
@@ -144,8 +142,8 @@ export function createICalendarWorkerPool(options = {}) {
|
|
|
144
142
|
WorkerClass ??= (await threads).Worker;
|
|
145
143
|
if (closed)
|
|
146
144
|
throw new ICalendarWorkerPoolClosedError();
|
|
147
|
-
// After the await
|
|
148
|
-
// ones before
|
|
145
|
+
// After the await, parses that arrived together resume in order, each seeing the queue as the
|
|
146
|
+
// ones before left it.
|
|
149
147
|
if (queue.length >= maxQueue && !hasIdleCapacity())
|
|
150
148
|
throw new ICalendarWorkerBusyError();
|
|
151
149
|
return new Promise((resolve, reject) => {
|