slotlock 0.8.0 → 0.10.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 (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. package/workspaces.js +433 -0
package/domains.d.ts ADDED
@@ -0,0 +1,117 @@
1
+ /** The label the record lives under: `_slotlock-challenge.<domain>`. */
2
+ export declare const SLOTLOCK_DOMAIN_RECORD_LABEL = "_slotlock-challenge";
3
+ /** What the record's value starts with, before the token. */
4
+ export declare const SLOTLOCK_DOMAIN_RECORD_PREFIX = "slotlock-verification=";
5
+ /** A token: 128 random bits in RFC 4648 base32, lowercase, unpadded (26 characters). */
6
+ export declare const SLOTLOCK_DOMAIN_TOKEN: RegExp;
7
+ /** Why a domain cannot be claimed. */
8
+ export type SlotlockDomainRefusal = 'invalid_domain' | 'public_suffix' | 'consumer_domain';
9
+ /** A domain as Slotlock keys it (ASCII, lowercase, punycode) and as people read it. */
10
+ export interface SlotlockDomain {
11
+ ascii: string;
12
+ unicode: string;
13
+ }
14
+ /**
15
+ * Domains whose addresses anyone can get: a team cannot claim them, so their addresses never join
16
+ * a team by domain. The large providers only (Atlassian, Notion and WorkOS refuse the same).
17
+ */
18
+ export declare const SLOTLOCK_CONSUMER_EMAIL_DOMAINS: ReadonlySet<string>;
19
+ /**
20
+ * The domain someone typed, as Slotlock keys it, or why it cannot be claimed: lowercased, mapped
21
+ * and punycoded as a URL host is (so `BÜCHER.de` is `xn--bcher-kva.de`), one trailing dot dropped;
22
+ * never an IP, a single label, an empty label, a special-use name (RFC 6761: example, test,
23
+ * localhost, …), a top-level domain the Public Suffix List does not know, or a public suffix
24
+ * itself, ICANN's (`co.uk`) or a private one (`github.io`, `workers.dev`).
25
+ */
26
+ export declare function normalizeSlotlockDomain(input: string): SlotlockDomain | {
27
+ refusal: SlotlockDomainRefusal;
28
+ };
29
+ /**
30
+ * The domain of an email address as normalizeSlotlockDomain keys it, or null: what a verified
31
+ * address is matched on (exactly: `a@sub.example.org` is not at `example.org`).
32
+ */
33
+ export declare function slotlockEmailDomain(email: string): string | null;
34
+ /** A new token for one claim: 128 bits from the platform's CSPRNG, never derived from the domain. */
35
+ export declare function slotlockDomainToken(): string;
36
+ /** The record a team publishes for a claim. */
37
+ export declare function slotlockDomainRecord(domain: string, token: string): {
38
+ name: string;
39
+ value: string;
40
+ };
41
+ /** What one resolver said about a name's TXT records. */
42
+ export type SlotlockTxtAnswer =
43
+ /** The name exists: its TXT values (none when it has other records only). */
44
+ {
45
+ status: 'ok';
46
+ values: string[];
47
+ ad: boolean;
48
+ ttl: number | null;
49
+ }
50
+ /** The name does not exist (NXDOMAIN). */
51
+ | {
52
+ status: 'nxdomain';
53
+ ad: boolean;
54
+ }
55
+ /** No answer worth acting on: SERVFAIL, refused, a timeout, an HTTP error, a malformed body. */
56
+ | {
57
+ status: 'error';
58
+ reason: string;
59
+ };
60
+ /** A resolver: asked for a name's TXT records, it answers, never throws. */
61
+ export interface SlotlockDnsResolver {
62
+ readonly name: string;
63
+ txt(name: string): Promise<SlotlockTxtAnswer>;
64
+ }
65
+ /** Whether the record a claim needs is there: `present`, `absent`, or `error` (decides nothing). */
66
+ export type SlotlockRecordVerdict = 'present' | 'absent' | 'error';
67
+ /**
68
+ * A TXT record's text from its presentation form: the quoted character-strings joined (`"a" "b"`
69
+ * is `ab`, with `\"` and `\\` unescaped), or the text as it is when it is not quoted. Cloudflare's
70
+ * JSON quotes TXT data and Google's does not (live queries, 2026-11-11): both read the same here.
71
+ */
72
+ export declare function slotlockTxtText(data: string): string;
73
+ /**
74
+ * A DNS-over-HTTPS JSON answer (Cloudflare's application/dns-json, Google's /resolve) as an answer:
75
+ * Status 0 with the type-16 records of the name asked for (none is NODATA), 3 as NXDOMAIN, anything
76
+ * else an error. A name that is an alias (a CNAME or DNAME in the answer) holds no record of its
77
+ * own: a wildcard CNAME to a host someone else controls must not let them prove the domain.
78
+ */
79
+ export declare function slotlockDohTxtAnswer(body: unknown, asked?: string): SlotlockTxtAnswer;
80
+ /** The public DNS-over-HTTPS JSON endpoints Slotlock asks (their docs, read 2026-11-11). */
81
+ export declare const SLOTLOCK_DOH_ENDPOINTS: Readonly<{
82
+ cloudflare: "https://cloudflare-dns.com/dns-query";
83
+ google: "https://dns.google/resolve";
84
+ }>;
85
+ /** A DNS-over-HTTPS resolver: Cloudflare's or Google's JSON API, through fetch. */
86
+ export declare function createSlotlockDohResolver(name: keyof typeof SLOTLOCK_DOH_ENDPOINTS, options?: {
87
+ fetch?: typeof fetch;
88
+ timeoutMs?: number;
89
+ }): SlotlockDnsResolver;
90
+ /** The system's resolver (node:dns), for a self-hosted server: loaded only when asked for. */
91
+ export declare function createSlotlockSystemResolver(options?: {
92
+ timeoutMs?: number;
93
+ }): SlotlockDnsResolver;
94
+ /** Whether an answer holds exactly the value expected (never a substring of another). */
95
+ export declare function slotlockRecordVerdict(answer: SlotlockTxtAnswer, expected: string): SlotlockRecordVerdict;
96
+ /** What a check of one claim found, for record_domain_check and the audit log. */
97
+ export interface SlotlockDomainCheck {
98
+ /** The first resolver's verdict: verifying needs `present` from it. */
99
+ primary: SlotlockRecordVerdict;
100
+ /** The second's, or null with one resolver: lapsing needs `absent` from both. */
101
+ secondary: SlotlockRecordVerdict | null;
102
+ /** Per resolver: what it said, for the audit log (the values are public DNS data). */
103
+ detail: {
104
+ resolver: string;
105
+ verdict: SlotlockRecordVerdict;
106
+ ad: boolean | null;
107
+ ttl: number | null;
108
+ values: string[];
109
+ reason: string | null;
110
+ }[];
111
+ }
112
+ /** Ask each resolver (at most two) for a claim's record. */
113
+ export declare function checkSlotlockDomain(resolvers: readonly SlotlockDnsResolver[], domain: string, token: string): Promise<SlotlockDomainCheck>;
114
+ /** The resolvers for SLOTLOCK_DNS_RESOLVER: DNS-over-HTTPS (Cloudflare, then Google), or the system's. */
115
+ export declare function slotlockDomainResolvers(mode: 'doh' | 'system', options?: {
116
+ fetch?: typeof fetch;
117
+ }): SlotlockDnsResolver[];
package/domains.js ADDED
@@ -0,0 +1,340 @@
1
+ // Verified domains (teams phase 5): a team proves it controls a domain with a TXT record at
2
+ // _slotlock-challenge.<domain> holding slotlock-verification=<token>, a token of 128 random bits
3
+ // drawn for that one claim. Slotlock looks the record up again every day: a domain stays verified
4
+ // only while its record stays (Atlassian's rule, support.atlassian.com "Verify a domain", read
5
+ // 2026-11-11), so a domain that changes hands lapses instead of handing its new owner the team.
6
+ //
7
+ // This module is what both runtimes share: the one normaliser every domain a person types and every
8
+ // email's domain go through, the refusals (public suffixes, consumer mail, special-use names), the
9
+ // token, and the lookups. A Worker has no UDP or TCP to DNS servers, so the lookup there is
10
+ // DNS-over-HTTPS JSON (Cloudflare's, cross-checked by Google's); a self-hosted server may use the
11
+ // system's resolver instead, which follows split-horizon corporate DNS.
12
+ import { parse } from 'tldts';
13
+ /** The label the record lives under: `_slotlock-challenge.<domain>`. */
14
+ export const SLOTLOCK_DOMAIN_RECORD_LABEL = '_slotlock-challenge';
15
+ /** What the record's value starts with, before the token. */
16
+ export const SLOTLOCK_DOMAIN_RECORD_PREFIX = 'slotlock-verification=';
17
+ /** A token: 128 random bits in RFC 4648 base32, lowercase, unpadded (26 characters). */
18
+ export const SLOTLOCK_DOMAIN_TOKEN = /^[a-z2-7]{26}$/;
19
+ /**
20
+ * Domains whose addresses anyone can get: a team cannot claim them, so their addresses never join
21
+ * a team by domain. The large providers only (Atlassian, Notion and WorkOS refuse the same).
22
+ */
23
+ export const SLOTLOCK_CONSUMER_EMAIL_DOMAINS = new Set([
24
+ '126.com', '163.com', 'aim.com', 'aol.com', 'att.net', 'btinternet.com', 'comcast.net', 'duck.com',
25
+ 'fastmail.com', 'free.fr', 'gmail.com', 'gmx.com', 'gmx.de', 'gmx.net', 'googlemail.com', 'hey.com',
26
+ 'hotmail.co.uk', 'hotmail.com', 'hotmail.fr', 'hushmail.com', 'icloud.com', 'inbox.com', 'laposte.net',
27
+ 'libero.it', 'live.com', 'mac.com', 'mail.com', 'mail.ru', 'mailinator.com', 'me.com', 'msn.com',
28
+ 'naver.com', 'orange.fr', 'outlook.com', 'pm.me', 'proton.me', 'protonmail.com', 'qq.com', 'rediffmail.com',
29
+ 'rocketmail.com', 't-online.de', 'tuta.io', 'tutanota.com', 'verizon.net', 'web.de', 'yahoo.co.uk',
30
+ 'yahoo.com', 'yahoo.fr', 'yandex.com', 'yandex.ru', 'ymail.com', 'zoho.com',
31
+ ]);
32
+ const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
33
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
34
+ /**
35
+ * The domain someone typed, as Slotlock keys it, or why it cannot be claimed: lowercased, mapped
36
+ * and punycoded as a URL host is (so `BÜCHER.de` is `xn--bcher-kva.de`), one trailing dot dropped;
37
+ * never an IP, a single label, an empty label, a special-use name (RFC 6761: example, test,
38
+ * localhost, …), a top-level domain the Public Suffix List does not know, or a public suffix
39
+ * itself, ICANN's (`co.uk`) or a private one (`github.io`, `workers.dev`).
40
+ */
41
+ export function normalizeSlotlockDomain(input) {
42
+ const typed = input.trim();
43
+ if (typed === '' || typed.length > 300 || /[@/\\:*?#\s[\]%]/.test(typed))
44
+ return { refusal: 'invalid_domain' };
45
+ let host;
46
+ try {
47
+ host = new URL(`http://${typed}`).hostname;
48
+ }
49
+ catch {
50
+ return { refusal: 'invalid_domain' };
51
+ }
52
+ if (host.endsWith('.'))
53
+ host = host.slice(0, -1);
54
+ if (host.length > 253 || IPV4.test(host) || host.startsWith('['))
55
+ return { refusal: 'invalid_domain' };
56
+ const labels = host.split('.');
57
+ // The URL parser keeps `example..com` as it is: an empty label is refused here.
58
+ if (labels.length < 2 || !labels.every((label) => LABEL.test(label)))
59
+ return { refusal: 'invalid_domain' };
60
+ const found = parse(host, { allowPrivateDomains: true, detectIp: true, detectSpecialUse: true, extractHostname: false });
61
+ if (found.isIp || found.isSpecialUse)
62
+ return { refusal: 'invalid_domain' };
63
+ if (!found.isIcann && !found.isPrivate)
64
+ return { refusal: 'invalid_domain' };
65
+ if (found.domain === null || found.publicSuffix === host)
66
+ return { refusal: 'public_suffix' };
67
+ if (SLOTLOCK_CONSUMER_EMAIL_DOMAINS.has(host))
68
+ return { refusal: 'consumer_domain' };
69
+ const unicode = labels.map((label) => (label.startsWith('xn--') ? decodePunycode(label.slice(4)) : label));
70
+ if (unicode.some((label) => label === null))
71
+ return { refusal: 'invalid_domain' };
72
+ return { ascii: host, unicode: unicode.join('.') };
73
+ }
74
+ /**
75
+ * The domain of an email address as normalizeSlotlockDomain keys it, or null: what a verified
76
+ * address is matched on (exactly: `a@sub.example.org` is not at `example.org`).
77
+ */
78
+ export function slotlockEmailDomain(email) {
79
+ const at = email.lastIndexOf('@');
80
+ if (at < 1 || at === email.length - 1)
81
+ return null;
82
+ const domain = normalizeSlotlockDomain(email.slice(at + 1));
83
+ return 'ascii' in domain ? domain.ascii : null;
84
+ }
85
+ const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567';
86
+ /** A new token for one claim: 128 bits from the platform's CSPRNG, never derived from the domain. */
87
+ export function slotlockDomainToken() {
88
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
89
+ let bits = 0;
90
+ let value = 0;
91
+ let token = '';
92
+ for (const byte of bytes) {
93
+ value = (value << 8) | byte;
94
+ bits += 8;
95
+ while (bits >= 5) {
96
+ token += BASE32[(value >>> (bits - 5)) & 31];
97
+ bits -= 5;
98
+ }
99
+ }
100
+ if (bits > 0)
101
+ token += BASE32[(value << (5 - bits)) & 31];
102
+ return token;
103
+ }
104
+ /** The record a team publishes for a claim. */
105
+ export function slotlockDomainRecord(domain, token) {
106
+ return { name: `${SLOTLOCK_DOMAIN_RECORD_LABEL}.${domain}`, value: `${SLOTLOCK_DOMAIN_RECORD_PREFIX}${token}` };
107
+ }
108
+ /**
109
+ * A TXT record's text from its presentation form: the quoted character-strings joined (`"a" "b"`
110
+ * is `ab`, with `\"` and `\\` unescaped), or the text as it is when it is not quoted. Cloudflare's
111
+ * JSON quotes TXT data and Google's does not (live queries, 2026-11-11): both read the same here.
112
+ */
113
+ export function slotlockTxtText(data) {
114
+ const trimmed = data.trim();
115
+ if (!trimmed.startsWith('"'))
116
+ return trimmed;
117
+ let text = '';
118
+ let inside = false;
119
+ for (let index = 0; index < trimmed.length; index++) {
120
+ const character = trimmed[index];
121
+ if (!inside) {
122
+ if (character === '"')
123
+ inside = true;
124
+ continue;
125
+ }
126
+ if (character === '\\' && index + 1 < trimmed.length) {
127
+ const next = trimmed[index + 1];
128
+ // \DDD is a decimal octet (RFC 1035 5.1).
129
+ const octet = /^\d{3}$/.test(trimmed.slice(index + 1, index + 4)) ? Number(trimmed.slice(index + 1, index + 4)) : null;
130
+ if (octet !== null && octet <= 255) {
131
+ text += String.fromCharCode(octet);
132
+ index += 3;
133
+ }
134
+ else {
135
+ text += next;
136
+ index += 1;
137
+ }
138
+ continue;
139
+ }
140
+ if (character === '"') {
141
+ inside = false;
142
+ continue;
143
+ }
144
+ text += character;
145
+ }
146
+ return text;
147
+ }
148
+ /** A DNS name as answers spell it, compared: lowercase, without its trailing dot. */
149
+ const dnsName = (value) => (typeof value === 'string' ? value.toLowerCase().replace(/\.$/, '') : null);
150
+ /**
151
+ * A DNS-over-HTTPS JSON answer (Cloudflare's application/dns-json, Google's /resolve) as an answer:
152
+ * Status 0 with the type-16 records of the name asked for (none is NODATA), 3 as NXDOMAIN, anything
153
+ * else an error. A name that is an alias (a CNAME or DNAME in the answer) holds no record of its
154
+ * own: a wildcard CNAME to a host someone else controls must not let them prove the domain.
155
+ */
156
+ export function slotlockDohTxtAnswer(body, asked) {
157
+ if (typeof body !== 'object' || body === null)
158
+ return { status: 'error', reason: 'malformed' };
159
+ const answer = body;
160
+ const ad = answer.AD === true;
161
+ if (answer.Status === 3)
162
+ return { status: 'nxdomain', ad };
163
+ if (answer.Status !== 0)
164
+ return { status: 'error', reason: `status ${String(answer.Status)}` };
165
+ if (answer.Answer !== undefined && !Array.isArray(answer.Answer))
166
+ return { status: 'error', reason: 'malformed' };
167
+ const values = [];
168
+ let ttl = null;
169
+ const records = (answer.Answer ?? []);
170
+ if (records.some((record) => typeof record !== 'object' || record === null))
171
+ return { status: 'error', reason: 'malformed' };
172
+ if (records.some((record) => record.type === 5 || record.type === 39)) {
173
+ return { status: 'ok', values: [], ad, ttl: null };
174
+ }
175
+ for (const record of records) {
176
+ const { type, data, TTL, name } = record;
177
+ if (type !== 16)
178
+ continue;
179
+ if (asked !== undefined && dnsName(name) !== dnsName(asked))
180
+ continue;
181
+ if (typeof data !== 'string')
182
+ return { status: 'error', reason: 'malformed' };
183
+ values.push(slotlockTxtText(data));
184
+ if (typeof TTL === 'number' && Number.isFinite(TTL))
185
+ ttl = ttl === null ? TTL : Math.min(ttl, TTL);
186
+ }
187
+ return { status: 'ok', values, ad, ttl };
188
+ }
189
+ /** The public DNS-over-HTTPS JSON endpoints Slotlock asks (their docs, read 2026-11-11). */
190
+ export const SLOTLOCK_DOH_ENDPOINTS = Object.freeze({
191
+ cloudflare: 'https://cloudflare-dns.com/dns-query',
192
+ google: 'https://dns.google/resolve',
193
+ });
194
+ /** A DNS-over-HTTPS resolver: Cloudflare's or Google's JSON API, through fetch. */
195
+ export function createSlotlockDohResolver(name, options = {}) {
196
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
197
+ const timeoutMs = options.timeoutMs ?? 5_000;
198
+ return {
199
+ name,
200
+ async txt(host) {
201
+ const url = new URL(SLOTLOCK_DOH_ENDPOINTS[name]);
202
+ url.search = new URLSearchParams({ name: host, type: 'TXT' }).toString();
203
+ try {
204
+ const response = await fetchImpl(url.href, {
205
+ headers: { accept: 'application/dns-json', 'cache-control': 'no-cache' },
206
+ redirect: 'error',
207
+ signal: AbortSignal.timeout(timeoutMs),
208
+ });
209
+ if (!response.ok)
210
+ return { status: 'error', reason: `HTTP ${response.status}` };
211
+ const text = await response.text();
212
+ if (text.length > 65_536)
213
+ return { status: 'error', reason: 'too large' };
214
+ return slotlockDohTxtAnswer(JSON.parse(text), host);
215
+ }
216
+ catch (error) {
217
+ return { status: 'error', reason: error instanceof Error ? error.name : 'failed' };
218
+ }
219
+ },
220
+ };
221
+ }
222
+ /** The system's resolver (node:dns), for a self-hosted server: loaded only when asked for. */
223
+ export function createSlotlockSystemResolver(options = {}) {
224
+ const timeoutMs = options.timeoutMs ?? 5_000;
225
+ return {
226
+ name: 'system',
227
+ async txt(host) {
228
+ try {
229
+ const { Resolver } = await import('node:dns/promises');
230
+ const resolver = new Resolver({ timeout: timeoutMs, tries: 2 });
231
+ // An alias holds no record of its own (see slotlockDohTxtAnswer).
232
+ const aliases = await resolver.resolveCname(host).catch((error) => {
233
+ if (error.code === 'ENODATA' || error.code === 'ENOTFOUND')
234
+ return [];
235
+ throw error;
236
+ });
237
+ if (aliases.length > 0)
238
+ return { status: 'ok', values: [], ad: false, ttl: null };
239
+ const records = await resolver.resolveTxt(host);
240
+ // Each record arrives as its character-strings: joined, as DNS-over-HTTPS joins them.
241
+ return { status: 'ok', values: records.map((chunks) => chunks.join('')), ad: false, ttl: null };
242
+ }
243
+ catch (error) {
244
+ const code = error.code;
245
+ if (code === 'ENOTFOUND')
246
+ return { status: 'nxdomain', ad: false };
247
+ if (code === 'ENODATA')
248
+ return { status: 'ok', values: [], ad: false, ttl: null };
249
+ return { status: 'error', reason: typeof code === 'string' ? code : 'failed' };
250
+ }
251
+ },
252
+ };
253
+ }
254
+ /** Whether an answer holds exactly the value expected (never a substring of another). */
255
+ export function slotlockRecordVerdict(answer, expected) {
256
+ if (answer.status === 'error')
257
+ return 'error';
258
+ if (answer.status === 'nxdomain')
259
+ return 'absent';
260
+ return answer.values.includes(expected) ? 'present' : 'absent';
261
+ }
262
+ /** Ask each resolver (at most two) for a claim's record. */
263
+ export async function checkSlotlockDomain(resolvers, domain, token) {
264
+ const { name, value } = slotlockDomainRecord(domain, token);
265
+ const asked = await Promise.all(resolvers.slice(0, 2).map(async (resolver) => ({ resolver, answer: await resolver.txt(name) })));
266
+ const detail = asked.map(({ resolver, answer }) => ({
267
+ resolver: resolver.name,
268
+ verdict: slotlockRecordVerdict(answer, value),
269
+ ad: answer.status === 'error' ? null : answer.ad,
270
+ ttl: answer.status === 'ok' ? answer.ttl : null,
271
+ // What else sits at the label, cut short: enough to see a typo, never a page of it.
272
+ values: answer.status === 'ok' ? answer.values.slice(0, 5).map((text) => text.slice(0, 120)) : [],
273
+ reason: answer.status === 'error' ? answer.reason.slice(0, 60) : null,
274
+ }));
275
+ return { primary: detail[0]?.verdict ?? 'error', secondary: detail[1]?.verdict ?? null, detail };
276
+ }
277
+ /** The resolvers for SLOTLOCK_DNS_RESOLVER: DNS-over-HTTPS (Cloudflare, then Google), or the system's. */
278
+ export function slotlockDomainResolvers(mode, options = {}) {
279
+ if (mode === 'system')
280
+ return [createSlotlockSystemResolver()];
281
+ return [createSlotlockDohResolver('cloudflare', options), createSlotlockDohResolver('google', options)];
282
+ }
283
+ // RFC 3492 (Punycode) decoding, for showing a domain as people read it beside its ASCII form.
284
+ const BASE = 36;
285
+ const T_MIN = 1;
286
+ const T_MAX = 26;
287
+ function adapt(delta, points, first) {
288
+ let scaled = first ? Math.floor(delta / 700) : delta >> 1;
289
+ scaled += Math.floor(scaled / points);
290
+ let k = 0;
291
+ while (scaled > ((BASE - T_MIN) * T_MAX) >> 1) {
292
+ scaled = Math.floor(scaled / (BASE - T_MIN));
293
+ k += BASE;
294
+ }
295
+ return k + Math.floor(((BASE - T_MIN + 1) * scaled) / (scaled + 38));
296
+ }
297
+ function digitOf(code) {
298
+ if (code >= 48 && code <= 57)
299
+ return code - 22;
300
+ if (code >= 65 && code <= 90)
301
+ return code - 65;
302
+ if (code >= 97 && code <= 122)
303
+ return code - 97;
304
+ return BASE;
305
+ }
306
+ /** A punycode label's Unicode, or null when it is not valid punycode. */
307
+ function decodePunycode(input) {
308
+ const output = [];
309
+ const basic = input.lastIndexOf('-');
310
+ for (let index = 0; index < basic; index++)
311
+ output.push(input.charCodeAt(index));
312
+ let n = 128;
313
+ let bias = 72;
314
+ let i = 0;
315
+ for (let index = basic > 0 ? basic + 1 : 0; index < input.length;) {
316
+ const before = i;
317
+ let weight = 1;
318
+ for (let k = BASE;; k += BASE) {
319
+ if (index >= input.length)
320
+ return null;
321
+ const digit = digitOf(input.charCodeAt(index++));
322
+ if (digit >= BASE)
323
+ return null;
324
+ i += digit * weight;
325
+ const threshold = k <= bias ? T_MIN : k >= bias + T_MAX ? T_MAX : k - bias;
326
+ if (digit < threshold)
327
+ break;
328
+ weight *= BASE - threshold;
329
+ if (!Number.isSafeInteger(i) || weight > 2 ** 40)
330
+ return null;
331
+ }
332
+ bias = adapt(i - before, output.length + 1, before === 0);
333
+ n += Math.floor(i / (output.length + 1));
334
+ i %= output.length + 1;
335
+ if (n > 0x10ffff)
336
+ return null;
337
+ output.splice(i++, 0, n);
338
+ }
339
+ return String.fromCodePoint(...output);
340
+ }
package/event-feed.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Sql, TransactionSql } from 'postgres';
2
2
  type SlotlockSql = Sql | TransactionSql;
3
3
  /** What a change can be, as the feed and a webhook's `event_types` name it. */
4
- export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "coverage.stale", "waitlist.offered", "waitlist.expired", "approval.requested", "approval.granted", "approval.declined", "approval.expired", "webhook.disabled", "usage.threshold_reached", "resource.created", "resource.updated", "resource.archived", "resource.restored", "resource.deleted", "coverage.recorded", "coverage.deleted", "webhook.created", "webhook.enabled", "webhook.deleted", "key.created", "key.rotated", "key.revoked"];
4
+ export declare const SLOTLOCK_CHANGE_TYPES: readonly ["hold.created", "hold.updated", "hold.confirmed", "hold.released", "hold.expired", "hold.paid", "reservation.created", "reservation.updated", "reservation.cancelled", "reservation.checked_in", "reservation.no_show", "reservation.rescheduled", "reservation.completed", "event.created", "event.updated", "event.cancelled", "event.timezone_shifted", "event.timezone_conflict", "reservation.timezone_shifted", "reservation.timezone_conflict", "reminder.due", "coverage.stale", "coverage.conflict", "waitlist.offered", "waitlist.expired", "waitlist.joined", "waitlist.accepted", "waitlist.declined", "waitlist.lapsed", "waitlist.left", "approval.requested", "approval.granted", "approval.declined", "approval.expired", "webhook.disabled", "usage.threshold_reached", "resource.created", "resource.updated", "resource.archived", "resource.restored", "resource.deleted", "coverage.recorded", "coverage.deleted", "webhook.created", "webhook.enabled", "webhook.deleted", "key.created", "key.rotated", "key.revoked"];
5
5
  export type SlotlockChangeType = (typeof SLOTLOCK_CHANGE_TYPES)[number];
6
6
  /**
7
7
  * Where a change came from (withTenant's `origin`): an agent protocol (`mcp`, `a2a`), the REST
@@ -224,7 +224,7 @@ export interface SlotlockEventFeed {
224
224
  limit?: number;
225
225
  before?: string;
226
226
  }): Promise<SlotlockChange[]>;
227
- /** Delete up to `limit` of the tenant's changes older than the retention; how many went. */
227
+ /** Delete up to `limit` of the tenant's changes past retention (slotlock.prune_changes); how many went. */
228
228
  pruneChanges(params: {
229
229
  tenantRef: string;
230
230
  limit: number;
package/event-feed.js CHANGED
@@ -14,19 +14,37 @@ export const SLOTLOCK_CHANGE_TYPES = Object.freeze([
14
14
  'hold.confirmed',
15
15
  'hold.released',
16
16
  'hold.expired',
17
+ // The integrator's payment for a hold that waits for one (RFC 0004 C3).
18
+ 'hold.paid',
17
19
  'reservation.created',
18
20
  'reservation.updated',
19
21
  'reservation.cancelled',
20
22
  'reservation.checked_in',
21
23
  'reservation.no_show',
24
+ // A booking moved (also reservation.updated), and one whose end has passed (RFC 0004 B4).
25
+ 'reservation.rescheduled',
26
+ 'reservation.completed',
22
27
  'event.created',
23
28
  'event.updated',
24
29
  'event.cancelled',
25
30
  'event.timezone_shifted',
26
31
  'event.timezone_conflict',
32
+ // A reservation that carries its wall time, which now falls at another instant (RFC 0004 B9).
33
+ 'reservation.timezone_shifted',
34
+ 'reservation.timezone_conflict',
35
+ // A stored reminder fell due (RFC 0004 P3): the change's id is the send key.
36
+ 'reminder.due',
27
37
  'coverage.stale',
38
+ // A source reported busy time over a booking Slotlock already holds (RFC 0004 C1, C4).
39
+ 'coverage.conflict',
28
40
  'waitlist.offered',
29
41
  'waitlist.expired',
42
+ // A waitlist entry joined, its offer accepted, declined or lapsed, and an entry left (RFC 0004 B6).
43
+ 'waitlist.joined',
44
+ 'waitlist.accepted',
45
+ 'waitlist.declined',
46
+ 'waitlist.lapsed',
47
+ 'waitlist.left',
30
48
  // A write waiting for a person (approval policies): requested, then granted, declined or expired.
31
49
  'approval.requested',
32
50
  'approval.granted',
@@ -631,41 +649,13 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
631
649
  async pruneChanges(params) {
632
650
  if (!isIdentity(params.tenantRef))
633
651
  throw identityError();
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.
652
+ // Through slotlock.prune_changes: the serving role may only add changes and read them (RFC
653
+ // 0004 D2). It keeps retentionDays, or the tenant's plan's days if longer, and prunes oldest
654
+ // first in feed order up to the first change kept, so a reader starting over reads past the gap.
637
655
  const rows = await sql `
638
- WITH head AS MATERIALIZED (
639
- SELECT outbox.id, outbox.txid,
640
- outbox.occurred_at >= now() - make_interval(days => ${retentionDays})
641
- OR EXISTS (
642
- SELECT 1 FROM slotlock.webhook_endpoints endpoint
643
- WHERE endpoint.tenant_ref = outbox.tenant_ref AND endpoint.disabled_at IS NULL
644
- AND (outbox.txid, outbox.id) > (endpoint.cursor_txid, endpoint.cursor_id)
645
- ) AS kept
646
- FROM slotlock.outbox
647
- WHERE outbox.tenant_ref = ${params.tenantRef}
648
- AND outbox.txid < pg_snapshot_xmin(pg_current_snapshot())
649
- ORDER BY outbox.txid, outbox.id
650
- LIMIT ${params.limit}
651
- ), doomed AS MATERIALIZED (
652
- SELECT change.id, change.txid FROM head change
653
- WHERE NOT EXISTS (
654
- SELECT 1 FROM head keeper
655
- WHERE keeper.kept AND (keeper.txid, keeper.id) <= (change.txid, change.id))
656
- ), missed AS (
657
- -- A disabled endpoint that had not received one of them has lost it.
658
- UPDATE slotlock.webhook_endpoints endpoint SET changes_pruned = true
659
- WHERE endpoint.tenant_ref = ${params.tenantRef} AND endpoint.disabled_at IS NOT NULL
660
- AND NOT endpoint.changes_pruned
661
- AND EXISTS (
662
- SELECT 1 FROM doomed
663
- WHERE (doomed.txid, doomed.id) > (endpoint.cursor_txid, endpoint.cursor_id))
664
- ), deleted AS (
665
- DELETE FROM slotlock.outbox change USING doomed WHERE change.id = doomed.id RETURNING 1
666
- )
667
- SELECT count(*)::int AS deleted FROM deleted`;
668
- return rows[0]?.deleted ?? 0;
656
+ SELECT slotlock.prune_changes(${params.tenantRef}::text, ${params.limit}::integer,
657
+ ${retentionDays}::integer) AS deleted`;
658
+ return Number(rows[0]?.deleted ?? 0);
669
659
  },
670
660
  };
671
661
  }
@@ -0,0 +1,38 @@
1
+ import { type SlotlockHoliday } from './holidays.js';
2
+ /** Where a holiday calendar comes from, and what to call it. */
3
+ export interface SlotlockHolidaySource {
4
+ /** The iCalendar text, pasted or uploaded. */
5
+ icalendar?: string;
6
+ /** An https URL to fetch it from. */
7
+ url?: string;
8
+ /**
9
+ * The calendar's name at its scope (1-200 characters): importing again under it replaces what
10
+ * it said. By default the calendar's own (X-WR-CALNAME), else the URL's host, else
11
+ * "Holidays".
12
+ */
13
+ name?: string;
14
+ /** Keep the URL and import it again every 30 days (maintenance). Needs `url`. */
15
+ subscribe?: boolean;
16
+ /** The first and last days to import, `YYYY-MM-DD`; by default today and two years on. */
17
+ from?: string;
18
+ to?: string;
19
+ }
20
+ /** A holiday calendar read and parsed, ready for importHolidayCalendar. */
21
+ export interface SlotlockHolidayCalendarRead {
22
+ name: string;
23
+ holidays: SlotlockHoliday[];
24
+ /** Events that were not all-day, cancelled, or outside the span. */
25
+ skipped: number;
26
+ span: {
27
+ from: string;
28
+ to: string;
29
+ };
30
+ /** The URL to keep, for a subscription. */
31
+ url?: string;
32
+ }
33
+ /**
34
+ * Read a holiday calendar from its text or URL (`fetcher` fetches it; without one a URL is
35
+ * refused `holiday_url_not_served`), parse it over its span, and name it. Throws
36
+ * `invalid_holiday_calendar` and `holiday_calendar_unreachable`.
37
+ */
38
+ export declare function readSlotlockHolidayCalendar(source: SlotlockHolidaySource, fetcher?: typeof fetch): Promise<SlotlockHolidayCalendarRead>;
@@ -0,0 +1,56 @@
1
+ // Reading a holiday calendar to import (RFC 0004 B1), shared by the application API, the dashboard,
2
+ // the CLI and maintenance: pasted or uploaded text, or an https URL fetched before any transaction
3
+ // opens, parsed over a span of days, and named, so importing it again replaces what it said.
4
+ import { dayToDate, epochDay } from './hours.js';
5
+ import { fetchHolidayCalendar, invalidHolidayCalendar, parseHolidayCalendar } from './holidays.js';
6
+ /** Days a span may cover, and the default. */
7
+ const MAX_SPAN_DAYS = 1_096;
8
+ const DEFAULT_SPAN_DAYS = 730;
9
+ /** A URL's host, as a calendar's name: never its path or query, which may hold its secret. */
10
+ function nameOfUrl(url) {
11
+ try {
12
+ return [...new URL(url).host].slice(0, 200).join('') || 'Holidays';
13
+ }
14
+ catch {
15
+ return 'Holidays';
16
+ }
17
+ }
18
+ /**
19
+ * Read a holiday calendar from its text or URL (`fetcher` fetches it; without one a URL is
20
+ * refused `holiday_url_not_served`), parse it over its span, and name it. Throws
21
+ * `invalid_holiday_calendar` and `holiday_calendar_unreachable`.
22
+ */
23
+ export async function readSlotlockHolidayCalendar(source, fetcher) {
24
+ const given = [source.icalendar, source.url].filter((value) => value !== undefined && value !== '');
25
+ if (given.length !== 1)
26
+ throw invalidHolidayCalendar('Give the calendar as text or as a URL, one of them');
27
+ if (source.subscribe === true && !source.url)
28
+ throw invalidHolidayCalendar('Only a calendar read from a URL can be subscribed to');
29
+ const today = Math.floor(Date.now() / 86_400_000);
30
+ const from = source.from ?? dayToDate(today);
31
+ const to = source.to ?? dayToDate((epochDay(from) ?? today) + DEFAULT_SPAN_DAYS);
32
+ const first = epochDay(from);
33
+ const last = epochDay(to);
34
+ if (first === null || last === null || last < first || last - first > MAX_SPAN_DAYS) {
35
+ throw invalidHolidayCalendar('from and to must be days at most three years apart');
36
+ }
37
+ let text;
38
+ if (source.url) {
39
+ if (!fetcher) {
40
+ throw Object.assign(new Error('This server does not fetch calendars by URL'), { code: 'holiday_url_not_served' });
41
+ }
42
+ text = await fetchHolidayCalendar(fetcher, source.url);
43
+ }
44
+ else {
45
+ text = source.icalendar;
46
+ }
47
+ const parsed = parseHolidayCalendar(text, { from, to });
48
+ const name = source.name?.trim() || parsed.name || (source.url ? nameOfUrl(source.url) : 'Holidays');
49
+ return {
50
+ name,
51
+ holidays: parsed.holidays,
52
+ skipped: parsed.skipped,
53
+ span: { from, to },
54
+ ...(source.subscribe === true && source.url ? { url: source.url } : {}),
55
+ };
56
+ }