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/ddl.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the caller's search_path back at the end, so a caller that keeps going in the same transaction
|
|
12
12
|
// resolves its own names as before. Apply it in one transaction: under autocommit every statement
|
|
13
13
|
// is a transaction of its own, and the pin ends with the statement that sets it.
|
|
14
|
-
import { SLOTLOCK_CHANGE_ORIGINS,
|
|
14
|
+
import { SLOTLOCK_CHANGE_ORIGINS, legacyCoverageSourceDigestSql } from './event-feed.js';
|
|
15
15
|
import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
|
|
16
16
|
const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
|
|
17
17
|
const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
|
|
@@ -75,9 +75,11 @@ $slotlock_restore_forced_row_security$;
|
|
|
75
75
|
export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
|
|
76
76
|
/**
|
|
77
77
|
* The version of the schema this release creates, recorded in `slotlock.schema_version` by every
|
|
78
|
-
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2.
|
|
78
|
+
* migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2; 0.3 to 0.5
|
|
79
|
+
* recorded 3. 0.6 is 4, so a 0.5 release refuses it before it changes anything: its DDL would put
|
|
80
|
+
* back the trigger that names a coverage source by its guessable digest.
|
|
79
81
|
*/
|
|
80
|
-
export const SLOTLOCK_SCHEMA_VERSION =
|
|
82
|
+
export const SLOTLOCK_SCHEMA_VERSION = 4;
|
|
81
83
|
/** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
|
|
82
84
|
export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
|
|
83
85
|
/** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
|
|
@@ -751,6 +753,12 @@ CREATE TABLE IF NOT EXISTS slotlock.schema_version (
|
|
|
751
753
|
);
|
|
752
754
|
ALTER TABLE slotlock.schema_version ENABLE ROW LEVEL SECURITY;
|
|
753
755
|
REVOKE ALL ON slotlock.schema_version FROM PUBLIC;
|
|
756
|
+
-- The rename of coverage changes earlier releases wrote (0.6, slotlockCoverageChangeRenameSql): the
|
|
757
|
+
-- last outbox id a pass has read (null before the first), and whether it is done. A pass after the
|
|
758
|
+
-- first that finds nothing to rename marks it done, and no migration reads the outbox for it again.
|
|
759
|
+
ALTER TABLE slotlock.schema_version ADD COLUMN IF NOT EXISTS coverage_changes_renamed_through bigint;
|
|
760
|
+
ALTER TABLE slotlock.schema_version
|
|
761
|
+
ADD COLUMN IF NOT EXISTS coverage_changes_renamed boolean NOT NULL DEFAULT false;
|
|
754
762
|
|
|
755
763
|
-- NULL when no migration has recorded a version (a schema from 0.2 or earlier).
|
|
756
764
|
CREATE OR REPLACE FUNCTION slotlock.installed_schema_version()
|
|
@@ -1664,6 +1672,29 @@ UPDATE slotlock.calendar_events
|
|
|
1664
1672
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error text;
|
|
1665
1673
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS last_error_at timestamptz;
|
|
1666
1674
|
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS stale_reported_at timestamptz;
|
|
1675
|
+
-- How the feed names a source (0.6): \`src_\` and 32 random hex digits, made when the source is first
|
|
1676
|
+
-- recorded for the resource and kept until it is deleted, so nobody can confirm a guessed source
|
|
1677
|
+
-- against it (0.5.3 named it by an unkeyed digest of the tenant and the source). Added bare, its
|
|
1678
|
+
-- default set apart (a volatile default on ADD COLUMN would rewrite the table under the lock), the
|
|
1679
|
+
-- sources already there given one each, and its CHECK NOT VALID, validated after the migration
|
|
1680
|
+
-- commits. Only listCoverage gives the source beside it, to the tenant.
|
|
1681
|
+
ALTER TABLE slotlock.calendar_coverage ADD COLUMN IF NOT EXISTS source_id text;
|
|
1682
|
+
ALTER TABLE slotlock.calendar_coverage
|
|
1683
|
+
ALTER COLUMN source_id SET DEFAULT 'src_' || pg_catalog.replace(pg_catalog.gen_random_uuid()::text, '-', '');
|
|
1684
|
+
UPDATE slotlock.calendar_coverage SET source_id = DEFAULT WHERE source_id IS NULL;
|
|
1685
|
+
DO $slotlock_coverage_source_id$
|
|
1686
|
+
BEGIN
|
|
1687
|
+
IF NOT EXISTS (
|
|
1688
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
1689
|
+
WHERE conrelid = 'slotlock.calendar_coverage'::regclass
|
|
1690
|
+
AND conname = 'slotlock_calendar_coverage_source_id_valid'
|
|
1691
|
+
) THEN
|
|
1692
|
+
ALTER TABLE slotlock.calendar_coverage
|
|
1693
|
+
ADD CONSTRAINT slotlock_calendar_coverage_source_id_valid
|
|
1694
|
+
CHECK (source_id IS NOT NULL AND source_id ~ '^src_[0-9a-f]{32}$') NOT VALID;
|
|
1695
|
+
END IF;
|
|
1696
|
+
END
|
|
1697
|
+
$slotlock_coverage_source_id$;
|
|
1667
1698
|
|
|
1668
1699
|
-- Configuration changes (RFC 0002 P1-6): a resource's own settings, the sources its coverage is
|
|
1669
1700
|
-- read from, and the tenant's webhook endpoints. Each change that alters something appends one row,
|
|
@@ -1743,7 +1774,7 @@ $slotlock_outbox_resource$;
|
|
|
1743
1774
|
|
|
1744
1775
|
-- A source first recorded, and deleted. Reading it again (a new revision) is progress, not a change
|
|
1745
1776
|
-- of what the resource is read from, and appends nothing; neither does a resource's deletion, which
|
|
1746
|
-
-- takes its coverage with it and is its own change. The source is named by its
|
|
1777
|
+
-- takes its coverage with it and is its own change. The source is named by its random source_id:
|
|
1747
1778
|
-- it can be an iCalendar URL with a token in it, or an address.
|
|
1748
1779
|
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_change()
|
|
1749
1780
|
RETURNS trigger
|
|
@@ -1763,10 +1794,10 @@ BEGIN
|
|
|
1763
1794
|
VALUES (
|
|
1764
1795
|
changed.tenant_ref, changed.resource_id,
|
|
1765
1796
|
CASE WHEN TG_OP = 'DELETE' THEN 'coverage.deleted' ELSE 'coverage.recorded' END,
|
|
1766
|
-
|
|
1797
|
+
changed.source_id,
|
|
1767
1798
|
jsonb_strip_nulls(jsonb_build_object(
|
|
1768
1799
|
'resource_id', changed.resource_id,
|
|
1769
|
-
'source_id',
|
|
1800
|
+
'source_id', changed.source_id,
|
|
1770
1801
|
'start', CASE WHEN TG_OP = 'INSERT' THEN changed.starts_at END,
|
|
1771
1802
|
'end', CASE WHEN TG_OP = 'INSERT' THEN changed.ends_at END,
|
|
1772
1803
|
'revision', CASE WHEN TG_OP = 'INSERT' THEN changed.revision END)),
|
|
@@ -1814,6 +1845,32 @@ CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
|
|
|
1814
1845
|
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
|
|
1815
1846
|
AFTER INSERT OR DELETE ON slotlock.calendar_coverage
|
|
1816
1847
|
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
|
|
1848
|
+
|
|
1849
|
+
-- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
|
|
1850
|
+
-- named by its source's source_id (src_redacted for a source it cannot find), without the source or
|
|
1851
|
+
-- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
|
|
1852
|
+
-- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
|
|
1853
|
+
-- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
|
|
1854
|
+
CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
|
|
1855
|
+
RETURNS trigger
|
|
1856
|
+
LANGUAGE plpgsql
|
|
1857
|
+
SECURITY INVOKER
|
|
1858
|
+
SET search_path = pg_catalog, slotlock, pg_temp
|
|
1859
|
+
AS $slotlock_outbox_coverage_in_shape$
|
|
1860
|
+
DECLARE
|
|
1861
|
+
named text;
|
|
1862
|
+
BEGIN
|
|
1863
|
+
IF ${coverageChangeInShapeSql('NEW')} THEN RETURN NEW; END IF;
|
|
1864
|
+
named := ${coverageChangeSourceIdSql('NEW')};
|
|
1865
|
+
NEW.subject := named;
|
|
1866
|
+
NEW.data := (NEW.data - 'source' - 'last_error') || pg_catalog.jsonb_build_object('source_id', named);
|
|
1867
|
+
RETURN NEW;
|
|
1868
|
+
END
|
|
1869
|
+
$slotlock_outbox_coverage_in_shape$;
|
|
1870
|
+
CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_in_shape
|
|
1871
|
+
BEFORE INSERT ON slotlock.outbox
|
|
1872
|
+
FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
|
|
1873
|
+
EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
|
|
1817
1874
|
CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
|
|
1818
1875
|
AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
|
|
1819
1876
|
FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
|
|
@@ -3139,8 +3196,108 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
|
|
|
3139
3196
|
CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
|
|
3140
3197
|
ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
|
|
3141
3198
|
${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
|
|
3199
|
+
/**
|
|
3200
|
+
* Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
|
|
3201
|
+
* or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
|
|
3202
|
+
*/
|
|
3203
|
+
function coverageChangeInShapeSql(row) {
|
|
3204
|
+
return `(${row}.subject ~ '^src_([0-9a-f]{32}|redacted)$'
|
|
3205
|
+
AND ${row}.data ->> 'source_id' IS NOT DISTINCT FROM ${row}.subject
|
|
3206
|
+
AND NOT ${row}.data ?| ARRAY['source', 'last_error'])`;
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* The source_id an earlier release's coverage change `row` names its source by: the source with
|
|
3210
|
+
* its tenant and resource whose source (0.5.2 and earlier: the subject, or `data.source`) or 0.5.3
|
|
3211
|
+
* digest (the subject) it carries, else src_redacted.
|
|
3212
|
+
*/
|
|
3213
|
+
function coverageChangeSourceIdSql(row) {
|
|
3214
|
+
return `COALESCE((
|
|
3215
|
+
SELECT coverage.source_id FROM slotlock.calendar_coverage coverage
|
|
3216
|
+
WHERE coverage.tenant_ref = ${row}.tenant_ref
|
|
3217
|
+
AND coverage.resource_id = ${row}.resource_id
|
|
3218
|
+
AND coverage.source_id IS NOT NULL
|
|
3219
|
+
AND (coverage.source = ${row}.subject
|
|
3220
|
+
OR coverage.source = ${row}.data ->> 'source'
|
|
3221
|
+
OR ${legacyCoverageSourceDigestSql('coverage.tenant_ref', 'coverage.source')} = ${row}.subject)
|
|
3222
|
+
LIMIT 1), 'src_redacted')`;
|
|
3223
|
+
}
|
|
3224
|
+
/** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
|
|
3225
|
+
export const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5_000;
|
|
3226
|
+
/**
|
|
3227
|
+
* One batch of the rename of coverage changes an earlier release wrote: of the outbox rows with ids
|
|
3228
|
+
* after `after` up to `head` (SQL expressions, bigint), the first `batch` by id, each `coverage.*`
|
|
3229
|
+
* one not yet in this release's shape gets its source's source_id as subject and `data.source_id`,
|
|
3230
|
+
* and loses `source` and `last_error`. Before 0.5.3 coverage.stale carried the source itself (an
|
|
3231
|
+
* iCalendar URL with a token, an address) and the error read from it; 0.5.3 named it by an unkeyed
|
|
3232
|
+
* digest anyone could confirm a guess against. A source deleted since is `src_redacted`: nothing
|
|
3233
|
+
* can match it any more, and a made-up id would look like a source the tenant could find. Selects
|
|
3234
|
+
* the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
|
|
3235
|
+
* `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
|
|
3236
|
+
* nothing.
|
|
3237
|
+
*/
|
|
3238
|
+
export function slotlockCoverageChangeRenameSql(after, head, batch, into = '') {
|
|
3239
|
+
return `WITH scanned AS MATERIALIZED (
|
|
3240
|
+
SELECT id FROM slotlock.outbox
|
|
3241
|
+
WHERE id > ${after} AND id <= ${head}
|
|
3242
|
+
ORDER BY id
|
|
3243
|
+
LIMIT ${batch}
|
|
3244
|
+
), earlier AS (
|
|
3245
|
+
SELECT change.id, change.tenant_ref, change.resource_id, change.subject, change.data
|
|
3246
|
+
FROM slotlock.outbox change
|
|
3247
|
+
WHERE change.id IN (SELECT scanned.id FROM scanned)
|
|
3248
|
+
AND change.type LIKE 'coverage.%'
|
|
3249
|
+
AND NOT ${coverageChangeInShapeSql('change')}
|
|
3250
|
+
FOR UPDATE OF change
|
|
3251
|
+
), named AS (
|
|
3252
|
+
SELECT earlier.id, ${coverageChangeSourceIdSql('earlier')} AS source_id
|
|
3253
|
+
FROM earlier
|
|
3254
|
+
), renamed AS (
|
|
3255
|
+
UPDATE slotlock.outbox change
|
|
3256
|
+
SET subject = named.source_id,
|
|
3257
|
+
data = (change.data - 'source' - 'last_error') || jsonb_build_object('source_id', named.source_id)
|
|
3258
|
+
FROM named
|
|
3259
|
+
WHERE change.id = named.id
|
|
3260
|
+
RETURNING change.id
|
|
3261
|
+
)
|
|
3262
|
+
SELECT (SELECT max(scanned.id) FROM scanned)::text AS last,
|
|
3263
|
+
(SELECT count(*) FROM renamed)::integer AS renamed${into ? `
|
|
3264
|
+
INTO ${into}` : ''}`;
|
|
3265
|
+
}
|
|
3266
|
+
// The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction, while FORCE is lifted (the
|
|
3267
|
+
// structure has the outbox under ACCESS EXCLUSIVE for the whole transaction, so no change commits
|
|
3268
|
+
// in between). It goes on from where the last pass stopped, and is skipped once done. applySchema
|
|
3269
|
+
// runs it in batches after the structure commits instead, each its own short transaction.
|
|
3270
|
+
const DEPLOYMENT_RENAME_COVERAGE_CHANGES = `DO $slotlock_rename_coverage_changes$
|
|
3271
|
+
DECLARE
|
|
3272
|
+
scan_after bigint;
|
|
3273
|
+
scan_head bigint;
|
|
3274
|
+
scan_last text;
|
|
3275
|
+
scan_renamed integer;
|
|
3276
|
+
renamed_total integer := 0;
|
|
3277
|
+
first_pass boolean;
|
|
3278
|
+
BEGIN
|
|
3279
|
+
IF (SELECT coverage_changes_renamed FROM slotlock.schema_version WHERE singleton) THEN
|
|
3280
|
+
RETURN;
|
|
3281
|
+
END IF;
|
|
3282
|
+
SELECT coverage_changes_renamed_through INTO scan_after FROM slotlock.schema_version WHERE singleton;
|
|
3283
|
+
first_pass := scan_after IS NULL;
|
|
3284
|
+
scan_after := COALESCE(scan_after, 0);
|
|
3285
|
+
SELECT COALESCE(max(id), 0) INTO scan_head FROM slotlock.outbox;
|
|
3286
|
+
LOOP
|
|
3287
|
+
${slotlockCoverageChangeRenameSql('scan_after', 'scan_head', String(SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH), 'scan_last, scan_renamed')};
|
|
3288
|
+
EXIT WHEN scan_last IS NULL;
|
|
3289
|
+
scan_after := scan_last::bigint;
|
|
3290
|
+
renamed_total := renamed_total + scan_renamed;
|
|
3291
|
+
END LOOP;
|
|
3292
|
+
UPDATE slotlock.schema_version
|
|
3293
|
+
SET coverage_changes_renamed_through = GREATEST(scan_after, scan_head),
|
|
3294
|
+
coverage_changes_renamed = NOT first_pass AND renamed_total = 0
|
|
3295
|
+
WHERE singleton;
|
|
3296
|
+
END
|
|
3297
|
+
$slotlock_rename_coverage_changes$;
|
|
3298
|
+
`;
|
|
3142
3299
|
/** The whole deployment DDL, run in one transaction: the schema, then its constraints validated. */
|
|
3143
|
-
export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
3300
|
+
export const SLOTLOCK_CORE_DDL = `${CORE_DDL_BODY}${DEPLOYMENT_RENAME_COVERAGE_CHANGES}${DEPLOYMENT_VALIDATE_CONSTRAINTS}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
|
|
3144
3301
|
/**
|
|
3145
3302
|
* SLOTLOCK_CORE_DDL without the validation, which applySchema runs as a step of its own after this
|
|
3146
3303
|
* commits (SLOTLOCK_VALIDATION_DDL).
|
package/event-feed.d.ts
CHANGED
|
@@ -14,17 +14,34 @@ export type SlotlockChangeOrigin = (typeof SLOTLOCK_CHANGE_ORIGINS)[number];
|
|
|
14
14
|
/** Whether `value` is one of SLOTLOCK_CHANGE_ORIGINS. */
|
|
15
15
|
export declare function isSlotlockChangeOrigin(value: unknown): value is SlotlockChangeOrigin;
|
|
16
16
|
/**
|
|
17
|
-
* The
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* The digest 0.5.3 named a coverage source by on the feed: `src_` and the first 24 hex digits of
|
|
18
|
+
* sha256(tenant, newline, source). Unkeyed, so anyone who knows the tenant could confirm a guessed
|
|
19
|
+
* source against it; 0.6 names a source by a random id instead (`calendar_coverage.source_id`), and
|
|
20
|
+
* the upgrade uses this only to find the changes 0.5.3 wrote and rename them. `tenant` and `source`
|
|
21
|
+
* are SQL expressions.
|
|
21
22
|
*/
|
|
22
|
-
export declare function
|
|
23
|
+
export declare function legacyCoverageSourceDigestSql(tenant: string, source: string): string;
|
|
23
24
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* 0.5.3 worked out a source's id here: an unkeyed digest of the tenant and the source, which let
|
|
26
|
+
* anyone who saw a change confirm a guessed source. Since 0.6 a source's `source_id` is random and
|
|
27
|
+
* only the database knows it, so this throws (`coverage_source_id_unavailable`) rather than give a
|
|
28
|
+
* digest nothing carries any more.
|
|
29
|
+
*
|
|
30
|
+
* @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
|
|
31
|
+
* be removed in 0.7.0.
|
|
26
32
|
*/
|
|
27
|
-
export declare function slotlockCoverageSourceId(
|
|
33
|
+
export declare function slotlockCoverageSourceId(_tenantRef: string, _source: string): never;
|
|
34
|
+
/**
|
|
35
|
+
* A change's subject and data as the feed serves them. A `coverage.*` change never carries its
|
|
36
|
+
* source (`source`) or the error read from it (`last_error`, which can quote it), and is named by a
|
|
37
|
+
* `source_id` of this release or `src_redacted`: an earlier release wrote both, and the upgrade
|
|
38
|
+
* renames those rows in batches after it commits, so a row it has not reached yet, or one an
|
|
39
|
+
* earlier release's server writes during a rolling upgrade, is served redacted.
|
|
40
|
+
*/
|
|
41
|
+
export declare function slotlockServedChange(type: string, subject: string, data: Record<string, unknown>): {
|
|
42
|
+
subject: string;
|
|
43
|
+
data: Record<string, unknown>;
|
|
44
|
+
};
|
|
28
45
|
/** How long the feed keeps a change, and so how far back a cursor may reach. */
|
|
29
46
|
export declare const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
|
|
30
47
|
/** Webhook endpoints one tenant may have. */
|
package/event-feed.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// endpoints it is delivered to. Rows are appended by triggers in the same transaction as the change
|
|
3
3
|
// (ddl.ts, SLOTLOCK_OUTBOX_DDL). These are the store's methods for reading them and for keeping each
|
|
4
4
|
// endpoint's delivery state; webhooks.ts signs and sends.
|
|
5
|
-
import {
|
|
5
|
+
import { 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));
|
|
@@ -65,20 +65,48 @@ export function isSlotlockChangeOrigin(value) {
|
|
|
65
65
|
return typeof value === 'string' && SLOTLOCK_CHANGE_ORIGINS.includes(value);
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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.
|
|
72
73
|
*/
|
|
73
|
-
export function
|
|
74
|
-
return `'src_' || left(encode(sha256(convert_to(${tenant} || E'\\n' || ${source}, 'UTF8')), 'hex'), 24)`;
|
|
74
|
+
export function legacyCoverageSourceDigestSql(tenant, source) {
|
|
75
|
+
return `'src_' || pg_catalog.left(pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(${tenant} || E'\\n' || ${source}, 'UTF8')), 'hex'), 24)`;
|
|
75
76
|
}
|
|
76
77
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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.
|
|
82
|
+
*
|
|
83
|
+
* @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
|
|
84
|
+
* be removed in 0.7.0.
|
|
79
85
|
*/
|
|
80
|
-
export function slotlockCoverageSourceId(
|
|
81
|
-
|
|
86
|
+
export function slotlockCoverageSourceId(_tenantRef, _source) {
|
|
87
|
+
throw Object.assign(new Error("slotlockCoverageSourceId cannot work out a source's id since 0.6: it is random. Read sourceId from listCoverage (source_id of slotlock_get_coverage)"), { code: 'coverage_source_id_unavailable' });
|
|
88
|
+
}
|
|
89
|
+
/** What a coverage source's `source_id` is: `src_` and 32 random hex digits. */
|
|
90
|
+
const SLOTLOCK_COVERAGE_SOURCE_ID = /^src_[0-9a-f]{32}$/;
|
|
91
|
+
/**
|
|
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.
|
|
95
|
+
*/
|
|
96
|
+
const SLOTLOCK_REDACTED_SOURCE_ID = 'src_redacted';
|
|
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.
|
|
103
|
+
*/
|
|
104
|
+
export function slotlockServedChange(type, subject, data) {
|
|
105
|
+
if (!type.startsWith('coverage.'))
|
|
106
|
+
return { subject, data };
|
|
107
|
+
const { source: _source, last_error: _lastError, ...rest } = data;
|
|
108
|
+
const sourceId = SLOTLOCK_COVERAGE_SOURCE_ID.test(subject) ? subject : SLOTLOCK_REDACTED_SOURCE_ID;
|
|
109
|
+
return { subject: sourceId, data: { ...rest, source_id: sourceId } };
|
|
82
110
|
}
|
|
83
111
|
/** How long the feed keeps a change, and so how far back a cursor may reach. */
|
|
84
112
|
export const SLOTLOCK_CHANGE_RETENTION_DAYS = 30;
|
|
@@ -105,8 +133,7 @@ function toChange(row) {
|
|
|
105
133
|
id: `msg_${row.id}`,
|
|
106
134
|
type: row.type,
|
|
107
135
|
resourceId: row.resource_id,
|
|
108
|
-
|
|
109
|
-
data: row.data,
|
|
136
|
+
...slotlockServedChange(row.type, row.subject, row.data),
|
|
110
137
|
occurredAt: row.occurred_at,
|
|
111
138
|
actor: row.actor,
|
|
112
139
|
// One a later release added, read by this one: unknown here, so unrecorded, not a failed page.
|
|
@@ -453,7 +480,11 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
|
|
|
453
480
|
FROM slotlock.outbox
|
|
454
481
|
WHERE tenant_ref = ${params.tenantRef}
|
|
455
482
|
AND (${params.resourceId ?? null}::uuid IS NULL OR resource_id = ${params.resourceId ?? null}::uuid)
|
|
456
|
-
AND (${params.subject ?? null}::text IS NULL OR
|
|
483
|
+
AND (${params.subject ?? null}::text IS NULL OR (
|
|
484
|
+
subject = ${params.subject ?? null}
|
|
485
|
+
-- A coverage change matches only by the name it is served under: one not renamed
|
|
486
|
+
-- yet may still hold its source, and a guess must not find it.
|
|
487
|
+
AND (type NOT LIKE 'coverage.%' OR subject ~ '^src_([0-9a-f]{32}|redacted)$')))
|
|
457
488
|
AND (${before}::bigint IS NULL OR id < ${before}::bigint)
|
|
458
489
|
ORDER BY id DESC
|
|
459
490
|
LIMIT ${limit}`;
|
package/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type { EmitVAvailabilityOptions, JSCalendarAlert, JSCalendarEvent, JSCale
|
|
|
12
12
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
13
13
|
export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
|
|
14
14
|
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
15
|
-
export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
|
|
15
|
+
export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockArgumentIssue, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
|
|
16
16
|
export { createSlotlockNodeServer } from './node-server.js';
|
|
17
17
|
export type { SlotlockNodeListenOptions, SlotlockNodeServer, SlotlockNodeServerAddress, SlotlockNodeServerCloseResult, SlotlockNodeServerErrorPhase, SlotlockNodeServerFetchTarget, SlotlockNodeServerOptions, } from './node-server.js';
|
|
18
18
|
export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
|
package/openapi.json
CHANGED
|
@@ -9701,7 +9701,7 @@
|
|
|
9701
9701
|
"get": {
|
|
9702
9702
|
"operationId": "slotlock_get_coverage",
|
|
9703
9703
|
"summary": "A resource's coverage and its health",
|
|
9704
|
-
"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.",
|
|
9704
|
+
"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.",
|
|
9705
9705
|
"tags": [
|
|
9706
9706
|
"Application"
|
|
9707
9707
|
],
|
|
@@ -9739,7 +9739,7 @@
|
|
|
9739
9739
|
},
|
|
9740
9740
|
"source_id": {
|
|
9741
9741
|
"type": "string",
|
|
9742
|
-
"pattern": "^src_[0-9a-f]{
|
|
9742
|
+
"pattern": "^src_[0-9a-f]{32}$"
|
|
9743
9743
|
},
|
|
9744
9744
|
"start": {
|
|
9745
9745
|
"type": "string",
|
|
@@ -12134,6 +12134,31 @@
|
|
|
12134
12134
|
"type": "string",
|
|
12135
12135
|
"description": "An overlap: the end it refused."
|
|
12136
12136
|
},
|
|
12137
|
+
"issues": {
|
|
12138
|
+
"type": "array",
|
|
12139
|
+
"description": "`invalid_arguments` from argument validation: each field it refused (at most 20), by its path in the arguments, outermost first; `[]` is the arguments as a whole.",
|
|
12140
|
+
"items": {
|
|
12141
|
+
"type": "object",
|
|
12142
|
+
"required": [
|
|
12143
|
+
"path",
|
|
12144
|
+
"message"
|
|
12145
|
+
],
|
|
12146
|
+
"properties": {
|
|
12147
|
+
"path": {
|
|
12148
|
+
"type": "array",
|
|
12149
|
+
"items": {
|
|
12150
|
+
"type": [
|
|
12151
|
+
"string",
|
|
12152
|
+
"integer"
|
|
12153
|
+
]
|
|
12154
|
+
}
|
|
12155
|
+
},
|
|
12156
|
+
"message": {
|
|
12157
|
+
"type": "string"
|
|
12158
|
+
}
|
|
12159
|
+
}
|
|
12160
|
+
}
|
|
12161
|
+
},
|
|
12137
12162
|
"error": {
|
|
12138
12163
|
"type": "object",
|
|
12139
12164
|
"required": [
|
package/package.json
CHANGED
package/rest-api.js
CHANGED
|
@@ -404,6 +404,18 @@ export function slotlockOpenApiDocument(args) {
|
|
|
404
404
|
resource_id: { type: 'string', description: 'An overlap: the resource it refused.' },
|
|
405
405
|
start: { type: 'string', description: 'An overlap: the start it refused.' },
|
|
406
406
|
end: { type: 'string', description: 'An overlap: the end it refused.' },
|
|
407
|
+
issues: {
|
|
408
|
+
type: 'array',
|
|
409
|
+
description: '`invalid_arguments` from argument validation: each field it refused (at most 20), by its path in the arguments, outermost first; `[]` is the arguments as a whole.',
|
|
410
|
+
items: {
|
|
411
|
+
type: 'object',
|
|
412
|
+
required: ['path', 'message'],
|
|
413
|
+
properties: {
|
|
414
|
+
path: { type: 'array', items: { type: ['string', 'integer'] } },
|
|
415
|
+
message: { type: 'string' },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
},
|
|
407
419
|
error: {
|
|
408
420
|
type: 'object',
|
|
409
421
|
required: ['code'],
|
package/self-host.js
CHANGED
|
@@ -390,8 +390,13 @@ export async function migrateSlotlock(config, log) {
|
|
|
390
390
|
const owner = connect(config.migrateDatabaseUrl ?? config.databaseUrl, 1);
|
|
391
391
|
try {
|
|
392
392
|
const store = createSlotlockStore(owner);
|
|
393
|
-
|
|
394
|
-
|
|
393
|
+
try {
|
|
394
|
+
await store.applySchema();
|
|
395
|
+
await store.applyTenantRls();
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
throw await migrateRefusal(owner, config, error);
|
|
399
|
+
}
|
|
395
400
|
log.info('schema_applied');
|
|
396
401
|
if (config.migrateDatabaseUrl === undefined)
|
|
397
402
|
return;
|
|
@@ -412,6 +417,36 @@ export async function migrateSlotlock(config, log) {
|
|
|
412
417
|
await owner.end({ timeout: 5 });
|
|
413
418
|
}
|
|
414
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Migrate run as the serving role, with no SLOTLOCK_MIGRATE_DATABASE_URL, is refused: as
|
|
422
|
+
* `unsafe_slotlock_schema`, with one reason per object the role does not own, when a role that is
|
|
423
|
+
* not a superuser owns the schema, and with a permission error (42501) when a superuser does. Lead
|
|
424
|
+
* with the fix, and keep the code, the reasons and the database's message after it.
|
|
425
|
+
*/
|
|
426
|
+
async function migrateRefusal(sql, config, error) {
|
|
427
|
+
const code = error?.code;
|
|
428
|
+
if (config.migrateDatabaseUrl !== undefined ||
|
|
429
|
+
!(error instanceof Error) ||
|
|
430
|
+
(code !== 'unsafe_slotlock_schema' && code !== '42501')) {
|
|
431
|
+
return error;
|
|
432
|
+
}
|
|
433
|
+
let found;
|
|
434
|
+
try {
|
|
435
|
+
[found] = await sql `
|
|
436
|
+
SELECT current_user AS role, pg_catalog.pg_get_userbyid(ns.nspowner) AS owner
|
|
437
|
+
FROM pg_catalog.pg_namespace ns
|
|
438
|
+
WHERE ns.nspname = 'slotlock'`;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return error;
|
|
442
|
+
}
|
|
443
|
+
if (!found || found.role === found.owner)
|
|
444
|
+
return error;
|
|
445
|
+
return Object.assign(new Error(`set SLOTLOCK_MIGRATE_DATABASE_URL to the schema owner's URL: migrate connected as ${found.role} (DATABASE_URL), but ${found.owner} owns the slotlock schema, and only its owner can migrate it. DATABASE_URL stays the serving role's.\n${error.message}`, { cause: error }), {
|
|
446
|
+
code,
|
|
447
|
+
...('reasons' in error ? { reasons: error.reasons } : {}),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
415
450
|
async function databaseReachable(sql) {
|
|
416
451
|
let timeout;
|
|
417
452
|
try {
|
|
@@ -533,6 +568,19 @@ export async function startSlotlockServer(config, log) {
|
|
|
533
568
|
if (!schema?.present) {
|
|
534
569
|
throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
|
|
535
570
|
}
|
|
571
|
+
// Serving as the schema owner is allowed (one role can migrate and serve), but the owner can
|
|
572
|
+
// lift forced row-level security, drop the exclusion constraints and read any table that does
|
|
573
|
+
// not force it: say so once, at startup, rather than refuse.
|
|
574
|
+
const [owning] = await sql `
|
|
575
|
+
SELECT pg_catalog.pg_has_role(current_user, ns.nspowner, 'MEMBER') AS owns
|
|
576
|
+
FROM pg_catalog.pg_namespace ns
|
|
577
|
+
WHERE ns.nspname = 'slotlock'`;
|
|
578
|
+
if (owning?.owns) {
|
|
579
|
+
log.warn('serving_as_schema_owner', {
|
|
580
|
+
role: serving?.role,
|
|
581
|
+
message: "DATABASE_URL connects as the role that owns the slotlock schema. Row-level security does not confine its owner on tables that do not force it, and the owner can lift it: serve as a role that owns nothing (CREATE ROLE slotlock_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS) and set SLOTLOCK_MIGRATE_DATABASE_URL to the schema owner's URL so migrate grants it what it needs",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
536
584
|
// Migrated by another release: an older schema needs this release's migrate first, and a newer
|
|
537
585
|
// one has tables and rules this release's code does not know.
|
|
538
586
|
await assertSlotlockSchemaCurrent(sql);
|
package/slotlock.manifest.json
CHANGED
package/store.d.ts
CHANGED
|
@@ -897,7 +897,10 @@ export type CheckInReservationResult = {
|
|
|
897
897
|
/** One source's coverage of a resource, with its health (listCoverage). */
|
|
898
898
|
export interface SlotlockCoverageHealth {
|
|
899
899
|
source: string;
|
|
900
|
-
/**
|
|
900
|
+
/**
|
|
901
|
+
* How the change feed names the source, never the source itself: `src_` and 32 random hex
|
|
902
|
+
* digits, made when the source is first recorded for the resource and kept until it is deleted.
|
|
903
|
+
*/
|
|
901
904
|
sourceId: string;
|
|
902
905
|
start: Date;
|
|
903
906
|
end: Date;
|