ts / src / gate.ts
841 words
  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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
/**
 * Client-side domain gate: a safety net configured on `createGatedClient`
 * via `deniedDomains` / `allowedDomains` that stops domain-touching
 * MUTATIONS (POST/PUT/PATCH/DELETE) before any request reaches the API.
 * Reads are never gated.
 *
 * Entries are exact domain names ("example.ro") or wildcards
 * ("*.example.ro", matching any name under example.ro and the bare suffix
 * itself). Matching is case-insensitive and ignores trailing dots. Deny wins
 * over allow; when `allowedDomains` is set, anything not on it is denied.
 *
 * Because many registration endpoints address domains by registration id,
 * the gate fetches the account's registrations once at creation and keeps
 * an id → domain cache. Ids unknown to the cache (registered after the
 * client was created) are resolved with a single GET
 * /domains/registrations/{id} and cached; ids that still cannot be resolved
 * pass under a pure deny-list but FAIL CLOSED under an allow-list.
 * `refreshGate` re-fetches the whole cache.
 */
import type { Client } from './client.js';
import { configError, domainDenied } from './errors.js';
import { get, paginate } from './http.js';

export type GateRules = Readonly<{
  /** Names/patterns this client must never mutate, even though the account could. */
  deniedDomains?: readonly string[];
  /** When set, ONLY these names/patterns may be mutated; an empty list denies everything. */
  allowedDomains?: readonly string[];
}>;

export type Gate = Readonly<{
  rules: GateRules;
  /** registration id → normalized domain name */
  registrations: Map<string, string>;
}>;

/** What a gated operation is about to touch. */
export type GateTarget = Readonly<
  { domain: string | null | undefined } | { registrationId: string | null | undefined }
>;

type Decision = Readonly<{ domain: string | null; matchedPattern: string | null }>;

/** Lower-case, trimmed, no trailing dots; undefined for blank input. */
export const normalizeDomain = (domain: string | null | undefined): string | undefined => {
  const name = domain?.trim().toLowerCase().replace(/\.+$/, '');
  return name ? name : undefined;
};

const patternMatches = (pattern: string, name: string): boolean => {
  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: readonly string[], name: string): string | undefined =>
  patterns.find((pattern) => patternMatches(pattern, name));

const decide = (rules: GateRules, domain: string | undefined): Decision | undefined => {
  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: Client): Promise<Map<string, string>> => {
  const registrations = await paginate(client, '/domains/registrations', 'registrations', {
    query: { limit: 100 },
  });
  const index = new Map<string, string>();
  for (const { id, domain } of registrations) {
    const name = normalizeDomain(domain);
    if (id && name) index.set(String(id), name);
  }
  return index;
};

const fetchRegistrationDomain = async (client: Client, id: string): Promise<string | undefined> => {
  try {
    const { registration } = await get(client, '/domains/registrations/{id}', { params: { id } });
    return normalizeDomain(registration?.domain);
  } catch {
    return undefined;
  }
};

const resolveRegistration = async (client: Client, gate: Gate, id: string): Promise<string | undefined> => {
  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: Client, rules: GateRules): Promise<Client> => {
  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: 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: Client): Promise<ReadonlyMap<string, string> | undefined> => {
  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: Client, op: string, target: GateTarget): Promise<void> => {
  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);
};

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/ts.git