1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113 | import { configError, domainDenied } from './errors.js';
import { get, paginate } from './http.js';
/** Lower-case, trimmed, no trailing dots; undefined for blank input. */
export const normalizeDomain = (domain) => {
const name = domain?.trim().toLowerCase().replace(/\.+$/, '');
return name ? name : undefined;
};
const patternMatches = (pattern, name) => {
const normalized = normalizeDomain(pattern);
if (!normalized)
return false;
if (normalized.startsWith('*.')) {
const suffix = normalized.slice(2);
return name === suffix || name.endsWith(`.${suffix}`);
}
return normalized === name;
};
const matchPattern = (patterns, name) => patterns.find((pattern) => patternMatches(pattern, name));
const decide = (rules, domain) => {
const name = normalizeDomain(domain);
if (!name)
return rules.allowedDomains ? { domain: null, matchedPattern: null } : undefined;
const denied = rules.deniedDomains && matchPattern(rules.deniedDomains, name);
if (denied)
return { domain: name, matchedPattern: denied };
if (rules.allowedDomains && !matchPattern(rules.allowedDomains, name)) {
return { domain: name, matchedPattern: null };
}
return undefined;
};
const fetchRegistrationIndex = async (client) => {
const registrations = await paginate(client, '/domains/registrations', 'registrations', {
query: { limit: 100 },
});
const index = new Map();
for (const { id, domain } of registrations) {
const name = normalizeDomain(domain);
if (id && name)
index.set(String(id), name);
}
return index;
};
const fetchRegistrationDomain = async (client, id) => {
try {
const { registration } = await get(client, '/domains/registrations/{id}', { params: { id } });
return normalizeDomain(registration?.domain);
}
catch {
return undefined;
}
};
const resolveRegistration = async (client, gate, id) => {
const cached = gate.registrations.get(id);
if (cached)
return cached;
const domain = await fetchRegistrationDomain(client, id);
if (domain)
gate.registrations.set(id, domain);
return domain;
};
/**
* Return a copy of CLIENT with a domain gate attached. Eagerly builds the
* registration id → domain cache, which requires an authenticated client.
*/
export const attachGate = async (client, rules) => {
if (!client.apiKey?.trim()) {
throw configError('Domain gate options require an authenticated client (apiKey)', rules);
}
// undefined and [] differ: undefined means "no allow-list", [] means
// "allow nothing" — so copy the lists without coercing either.
const gate = {
rules: {
deniedDomains: rules.deniedDomains ? [...rules.deniedDomains] : undefined,
allowedDomains: rules.allowedDomains ? [...rules.allowedDomains] : undefined,
},
registrations: await fetchRegistrationIndex(client),
};
return Object.freeze({ ...client, gate });
};
/**
* Re-fetch the registration cache of CLIENT's gate (for long-lived clients
* that registered domains after creation). Returns the refreshed map, or
* undefined when the client has no gate.
*/
export const refreshGate = async (client) => {
const { gate } = client;
if (!gate)
return undefined;
const fresh = await fetchRegistrationIndex(client);
gate.registrations.clear();
for (const [id, domain] of fresh)
gate.registrations.set(id, domain);
return gate.registrations;
};
/**
* Assert that the mutation OP (e.g. "POST /domains/registrations") may touch
* TARGET, throwing a domainDenied error otherwise. A no-op for clients
* without a gate. Registration ids are resolved through the gate's cache,
* with a miss falling back to a single-registration fetch.
*/
export const checkGate = async (client, op, target) => {
const { gate } = client;
if (!gate)
return;
const domain = 'domain' in target
? (target.domain ?? undefined)
: target.registrationId == null
? undefined
: await resolveRegistration(client, gate, String(target.registrationId));
const denial = decide(gate.rules, domain);
if (denial)
throw domainDenied(denial.domain, denial.matchedPattern, op);
};
|