slotlock 0.9.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 (61) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-store-backend.js +209 -15
  5. package/application-api.d.ts +10 -1
  6. package/application-api.js +213 -2
  7. package/availability-store.js +13 -5
  8. package/booking-rules.d.ts +11 -0
  9. package/booking-rules.js +21 -1
  10. package/config.d.ts +50 -0
  11. package/config.js +107 -0
  12. package/dashboard-scope.d.ts +7 -0
  13. package/dashboard-scope.js +21 -0
  14. package/dashboard.d.ts +97 -2
  15. package/dashboard.js +3068 -284
  16. package/ddl-identity.d.ts +25 -0
  17. package/ddl-identity.js +881 -0
  18. package/ddl-scim.d.ts +5 -0
  19. package/ddl-scim.js +740 -0
  20. package/ddl-sso.d.ts +15 -0
  21. package/ddl-sso.js +1033 -0
  22. package/ddl.d.ts +40 -9
  23. package/ddl.js +2123 -49
  24. package/domains.d.ts +117 -0
  25. package/domains.js +340 -0
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +6 -34
  28. package/holiday-import.d.ts +1 -1
  29. package/holiday-import.js +2 -3
  30. package/holidays.js +70 -11
  31. package/index.d.ts +12 -4
  32. package/index.js +8 -2
  33. package/invitation-email.d.ts +26 -0
  34. package/invitation-email.js +73 -0
  35. package/maintenance.d.ts +7 -2
  36. package/maintenance.js +15 -1
  37. package/oidc.d.ts +135 -0
  38. package/oidc.js +452 -0
  39. package/openapi.json +1886 -6
  40. package/package.json +2 -1
  41. package/scim-server.d.ts +19 -0
  42. package/scim-server.js +243 -0
  43. package/scim.d.ts +180 -0
  44. package/scim.js +416 -0
  45. package/self-host.d.ts +19 -6
  46. package/self-host.js +262 -7
  47. package/slotlock.manifest.json +1 -1
  48. package/store.d.ts +273 -2
  49. package/store.js +984 -72
  50. package/types.d.ts +33 -1
  51. package/types.js +68 -1
  52. package/workspace-domains.d.ts +146 -0
  53. package/workspace-domains.js +249 -0
  54. package/workspace-roles.d.ts +47 -0
  55. package/workspace-roles.js +304 -0
  56. package/workspace-scim.d.ts +89 -0
  57. package/workspace-scim.js +157 -0
  58. package/workspace-sso.d.ts +131 -0
  59. package/workspace-sso.js +180 -0
  60. package/workspaces.d.ts +240 -0
  61. 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
@@ -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
@@ -649,41 +649,13 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
649
649
  async pruneChanges(params) {
650
650
  if (!isIdentity(params.tenantRef))
651
651
  throw identityError();
652
- // Oldest first in feed order, and only up to the first change kept (within retention, still
653
- // owed to an enabled webhook, or later than the oldest running transaction), so every kept
654
- // 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.
655
655
  const rows = await sql `
656
- WITH head AS MATERIALIZED (
657
- SELECT outbox.id, outbox.txid,
658
- outbox.occurred_at >= now() - make_interval(days => ${retentionDays})
659
- OR EXISTS (
660
- SELECT 1 FROM slotlock.webhook_endpoints endpoint
661
- WHERE endpoint.tenant_ref = outbox.tenant_ref AND endpoint.disabled_at IS NULL
662
- AND (outbox.txid, outbox.id) > (endpoint.cursor_txid, endpoint.cursor_id)
663
- ) AS kept
664
- FROM slotlock.outbox
665
- WHERE outbox.tenant_ref = ${params.tenantRef}
666
- AND outbox.txid < pg_snapshot_xmin(pg_current_snapshot())
667
- ORDER BY outbox.txid, outbox.id
668
- LIMIT ${params.limit}
669
- ), doomed AS MATERIALIZED (
670
- SELECT change.id, change.txid FROM head change
671
- WHERE NOT EXISTS (
672
- SELECT 1 FROM head keeper
673
- WHERE keeper.kept AND (keeper.txid, keeper.id) <= (change.txid, change.id))
674
- ), missed AS (
675
- -- A disabled endpoint that had not received one of them has lost it.
676
- UPDATE slotlock.webhook_endpoints endpoint SET changes_pruned = true
677
- WHERE endpoint.tenant_ref = ${params.tenantRef} AND endpoint.disabled_at IS NOT NULL
678
- AND NOT endpoint.changes_pruned
679
- AND EXISTS (
680
- SELECT 1 FROM doomed
681
- WHERE (doomed.txid, doomed.id) > (endpoint.cursor_txid, endpoint.cursor_id))
682
- ), deleted AS (
683
- DELETE FROM slotlock.outbox change USING doomed WHERE change.id = doomed.id RETURNING 1
684
- )
685
- SELECT count(*)::int AS deleted FROM deleted`;
686
- 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);
687
659
  },
688
660
  };
689
661
  }
@@ -7,7 +7,7 @@ export interface SlotlockHolidaySource {
7
7
  url?: string;
8
8
  /**
9
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 and path, else
10
+ * it said. By default the calendar's own (X-WR-CALNAME), else the URL's host, else
11
11
  * "Holidays".
12
12
  */
13
13
  name?: string;
package/holiday-import.js CHANGED
@@ -6,11 +6,10 @@ import { fetchHolidayCalendar, invalidHolidayCalendar, parseHolidayCalendar } fr
6
6
  /** Days a span may cover, and the default. */
7
7
  const MAX_SPAN_DAYS = 1_096;
8
8
  const DEFAULT_SPAN_DAYS = 730;
9
- /** A URL's host and path, as a calendar's name. */
9
+ /** A URL's host, as a calendar's name: never its path or query, which may hold its secret. */
10
10
  function nameOfUrl(url) {
11
11
  try {
12
- const parsed = new URL(url);
13
- return [...`${parsed.host}${parsed.pathname === '/' ? '' : parsed.pathname}`].slice(0, 200).join('');
12
+ return [...new URL(url).host].slice(0, 200).join('') || 'Holidays';
14
13
  }
15
14
  catch {
16
15
  return 'Holidays';
package/holidays.js CHANGED
@@ -10,6 +10,27 @@ export const SLOTLOCK_MAX_HOLIDAY_DAYS = 1_000;
10
10
  /** How long Slotlock waits for a holiday calendar URL. */
11
11
  const FETCH_TIMEOUT_MS = 10_000;
12
12
  const MAX_LABEL = 200;
13
+ /** Steps through one recurring event's occurrences, and through all of a calendar's. */
14
+ const MAX_STEPS_PER_EVENT = 5_000;
15
+ const MAX_STEPS_PER_CALENDAR = 20_000;
16
+ /**
17
+ * Repeats a calendar of days off has no use for, refused before they are expanded: ical.js steps a
18
+ * DAILY rule day by day until it finds a day of the month or year that may never come (February
19
+ * 30), and never returns; a rule more often than daily cannot name whole days.
20
+ */
21
+ const SUB_DAILY = new Set(['SECONDLY', 'MINUTELY', 'HOURLY']);
22
+ const DAILY_FILTERS = ['BYMONTHDAY', 'BYYEARDAY', 'BYWEEKNO'];
23
+ /** Why a recurrence rule cannot be read here, or null when it can. */
24
+ function unreadableRule(rule) {
25
+ const recur = rule;
26
+ const freq = String(recur?.freq ?? '').toUpperCase();
27
+ if (SUB_DAILY.has(freq))
28
+ return `it repeats ${freq.toLowerCase()}, and a holiday is a whole day`;
29
+ if (freq === 'DAILY' && DAILY_FILTERS.some((part) => recur?.parts?.[part] !== undefined)) {
30
+ return 'it repeats daily on days of the month or year: repeat it monthly or yearly instead';
31
+ }
32
+ return null;
33
+ }
13
34
  export function invalidHolidayCalendar(message) {
14
35
  return Object.assign(new Error(message), { code: 'invalid_holiday_calendar' });
15
36
  }
@@ -40,17 +61,30 @@ export function parseHolidayCalendar(text, span) {
40
61
  throw invalidHolidayCalendar('The text is not a VCALENDAR');
41
62
  const holidays = [];
42
63
  let skipped = 0;
64
+ let steps = 0;
65
+ const vevents = root.getAllSubcomponents('vevent');
66
+ // Each series' overrides, found once: ical.js otherwise looks through every event for each
67
+ // event, so a calendar of many events took minutes.
68
+ const overrides = new Map();
69
+ for (const component of vevents) {
70
+ if (!component.hasProperty('recurrence-id'))
71
+ continue;
72
+ const uid = String(component.getFirstPropertyValue('uid') ?? '');
73
+ overrides.set(uid, [...(overrides.get(uid) ?? []), component]);
74
+ }
43
75
  const add = (holiday) => {
44
76
  holidays.push(holiday);
45
77
  if (holidays.length > SLOTLOCK_MAX_HOLIDAY_DAYS) {
46
78
  throw invalidHolidayCalendar(`The calendar names more than ${SLOTLOCK_MAX_HOLIDAY_DAYS} holidays in the span`);
47
79
  }
48
80
  };
49
- for (const component of root.getAllSubcomponents('vevent')) {
81
+ for (const component of vevents) {
50
82
  // An override of one occurrence is read with its series.
51
83
  if (component.hasProperty('recurrence-id'))
52
84
  continue;
53
- const event = new ICAL.Event(component);
85
+ const event = new ICAL.Event(component, {
86
+ exceptions: overrides.get(String(component.getFirstPropertyValue('uid') ?? '')) ?? [],
87
+ });
54
88
  const start = event.startDate;
55
89
  const status = String(component.getFirstPropertyValue('status') ?? '').toUpperCase();
56
90
  if (!start || !start.isDate || status === 'CANCELLED') {
@@ -79,14 +113,31 @@ export function parseHolidayCalendar(text, span) {
79
113
  occurrence(startDay, uid || `${dateOf(start)}:${label}`);
80
114
  continue;
81
115
  }
82
- const iterator = event.iterator();
83
- for (let next = iterator.next(), steps = 0; next && steps < 5_000; next = iterator.next(), steps++) {
84
- const day = epochDay(dateOf(next));
85
- if (day > last)
86
- break;
87
- if (day + length - 1 < first)
88
- continue;
89
- occurrence(day, `${uid || label}:${dateOf(next)}`);
116
+ for (const rule of component.getAllProperties('rrule')) {
117
+ const why = unreadableRule(rule.getFirstValue());
118
+ if (why)
119
+ throw invalidHolidayCalendar(`"${label}" cannot be read: ${why}`);
120
+ }
121
+ try {
122
+ const iterator = event.iterator();
123
+ for (let next = iterator.next(), own = 0; next && own < MAX_STEPS_PER_EVENT; next = iterator.next(), own++) {
124
+ steps += 1;
125
+ if (steps > MAX_STEPS_PER_CALENDAR) {
126
+ throw invalidHolidayCalendar('The calendar repeats too much to read: give its holidays from a later start');
127
+ }
128
+ const day = epochDay(dateOf(next));
129
+ if (day > last)
130
+ break;
131
+ if (day + length - 1 < first)
132
+ continue;
133
+ occurrence(day, `${uid || label}:${dateOf(next)}`);
134
+ }
135
+ }
136
+ catch (error) {
137
+ if (error.code === 'invalid_holiday_calendar')
138
+ throw error;
139
+ // ical.js refusing a rule it cannot follow.
140
+ throw invalidHolidayCalendar(`"${label}" repeats in a way that cannot be read`);
90
141
  }
91
142
  }
92
143
  const given = String(root.getFirstPropertyValue('x-wr-calname') ?? '').replace(/\s+/g, ' ').trim();
@@ -136,7 +187,15 @@ export async function fetchHolidayCalendar(fetcher, url) {
136
187
  const chunks = [];
137
188
  let size = 0;
138
189
  for (;;) {
139
- const { done, value } = await reader.read();
190
+ let read;
191
+ try {
192
+ read = await reader.read();
193
+ }
194
+ catch {
195
+ // Cut off while its body was read (the timeout, or the connection reset).
196
+ throw unreachable('The calendar URL could not be read');
197
+ }
198
+ const { done, value } = read;
140
199
  if (done)
141
200
  break;
142
201
  size += value.byteLength;