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.
Files changed (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/event-feed.js CHANGED
@@ -1,8 +1,8 @@
1
- // The event feed (RFC 0002 P1-5): what changed for a tenant, read by position, and the webhook
2
- // endpoints it is delivered to. Rows are appended by triggers in the same transaction as the change
3
- // (ddl.ts, SLOTLOCK_OUTBOX_DDL). These are the store's methods for reading them and for keeping each
4
- // endpoint's delivery state; webhooks.ts signs and sends.
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
- // Configuration: what a resource is and how it books, the sources it is read from, where the
32
- // feed is delivered and the keys that reach the tenant. Ids and states, never a secret or a URL.
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, as the history and the feed record it (withTenant's `origin`): an agent
49
- * protocol (`mcp`, `a2a`), the REST API's agent routes (`rest`) or its application operations
50
- * (`application`), the dashboard, a member's dashboard assistant, maintenance, or code calling the
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 on the feed: `src_` and the first 24 hex digits of
69
- * sha256(tenant, newline, source). Unkeyed, so anyone who knows the tenant could confirm a guessed
70
- * source against it; 0.6 names a source by a random id instead (`calendar_coverage.source_id`), and
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.5.3 worked out a source's id here: an unkeyed digest of the tenant and the source, which let
79
- * anyone who saw a change confirm a guessed source. Since 0.6 a source's `source_id` is random and
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 could not be named any more: one an
93
- * earlier release recorded by the source itself (or by its 0.5.3 digest) after the source was
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 its
99
- * source (`source`) or the error read from it (`last_error`, which can quote it), and is named by a
100
- * `source_id` of this release or `src_redacted`: an earlier release wrote both, and the upgrade
101
- * renames those rows in batches after it commits, so a row it has not reached yet, or one an
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 = 10;
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
- // One a later release added, read by this one: unknown here, so unrecorded, not a failed page.
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
- return {
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 are read from one snapshot: a prune
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
- // Whichever server pruned it, and however long it keeps changes: reading on could skip one.
231
- // A 0.4 cursor also by its date, as 0.4 did: 0.4 pruned by age, not in feed order, so the
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
- // With nothing new the position stays, dated now: a reader that is up to date stays current.
245
- // Before any change, every one still to come is after the oldest transaction still running.
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 FROM slotlock.webhook_endpoints
288
- WHERE tenant_ref = ${params.tenantRef}`;
289
- if ((count?.count ?? 0) >= SLOTLOCK_MAX_WEBHOOK_ENDPOINTS) {
290
- return { ok: false, code: 'webhook_limit_reached', limit: SLOTLOCK_MAX_WEBHOOK_ENDPOINTS };
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 that has not ended yet is at or after this position.
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 (tenant_ref, url, secret, event_types, cursor_txid)
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, data, occurred_at, actor, origin,
385
- key_id::text
386
- FROM slotlock.outbox
387
- WHERE tenant_ref = ${params.tenantRef}
388
- AND txid < pg_snapshot_xmin(pg_current_snapshot())
389
- AND (txid, id) > (${endpoint.cursor_txid}::xid8, ${endpoint.cursor_id}::bigint)
390
- AND (${endpoint.event_types}::text[] IS NULL OR type = ANY (${endpoint.event_types}::text[]))
391
- ORDER BY txid, id
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 goes to the tenant's other endpoints, and a URL can
452
- // carry a token.
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: a change still within the
497
- // retention, one an enabled webhook has still to receive, or one of a transaction later than
498
- // the oldest still running. So every change kept is after every change pruned (the trigger
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 return it, and as the application API returns any of a
2
- // tenant's events. Its own module because both the agent server and the application API describe
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 trusted store's byte ceilings. A value admitted by a UTF-8 byte
11
- // ceiling can never exceed that many UTF-16 code units, so every valid stored/provider event is
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 (normalizeEmail in sync.ts), so every stored one can be
19
- // returned: an address from an imported feed need not be one z.email() takes (ops@localhost).
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. `onEvent` and `onError` are typed as returning nothing, but an
3
- * async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
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. `onEvent` and `onError` are typed as returning nothing, but an
3
- * async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
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') &&
@@ -1,5 +1,5 @@
1
- // One worker of createICalendarWorkerPool: reads the feeds it is sent with the ordinary parsers and
2
- // posts back what they return, or the code of the CalendarContractError they throw.
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
- // The parsers turn every failure into a CalendarContractError; anything else is a fault, and
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 on its worker, in ms, before the worker is stopped and the feed
32
- * refused (CalendarExpansionLimitError), 100-60,000. Defaults to 3,000: several times what the
33
- * parser's step budget takes, with room for a new worker to start.
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, which the
42
- * built package ships; Slotlock's own TypeScript sources cannot start it (tests pass a stand-in).
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 that read untrusted iCalendar for the calling thread. Results and
68
- * errors are those of `parseICalendarChanges`, `parseICalendarFeed` and
69
- * `parseTrustedICalendarEvents` (the same
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 that read untrusted iCalendar for the calling thread. Results and
34
- * errors are those of `parseICalendarChanges`, `parseICalendarFeed` and
35
- * `parseTrustedICalendarEvents` (the same
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 its worker stopped: whatever ical.js is doing,
104
- // terminate() ends it, and the next parse gets a new worker.
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: parses that arrived together resume in order, each seeing the queue as the
148
- // ones before it left it.
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) => {