1
0
mirror of https://github.com/google/nomulus synced 2026-07-18 14:02:35 +00:00

Compare commits

...

12 Commits

Author SHA1 Message Date
Weimin Yu 7e0489e5f9 Sanitize and parse logged TLDs using JSON arrays (#3164)
In FlowReporter, we extract TLDs from EPP domain commands and log them under 'tld' and 'tlds' metadata fields to generate ICANN activity reports. Previously, invalid or extremely long TLD names (such as email addresses or long domain labels in non-validated XML payloads) could break downstream log parsing.

To prevent this issue, this change does the following:

1. Java Sanitization:
   Introduces `toLogSafeLabel(...)` in `DomainFlowUtils`, which converts ASCII to lowercase, replaces any character outside of `[a-z0-9.-]` with `-`, and limits the length to 63 characters (appending '...' if truncated). FlowReporter now uses this method on guessed TLDs before logging them.

2. SQL Modernization:
   Upgrades `epp_metrics.sql` and `epp_metrics_test.sql` to use BigQuery's native `JSON_EXTRACT_STRING_ARRAY(json, '$.tlds')` instead of the legacy `SPLIT(REGEXP_EXTRACT(JSON_EXTRACT(...)))` workaround. Because the native function extracts clean string elements, it removes the need to strip quotation marks with additional regexes and prevents parsing failures on commas, spaces, or nested structures.

SQL change tested in BigQuery.

BUG=http://b/535230985
2026-07-17 19:42:00 +00:00
gbrodman d29a98bdd3 Handle RegistryLock unlock edge case with doubly-applied lock (#3159)
We allow admin locks to overwrite already-applied locks. In the case
where that happens in between an unlock request and an unlock
completion, we shouldn't allow the unlock completion to go through.
2026-07-17 17:36:26 +00:00
gbrodman d6dd95b052 Only delete Workspace accounts if we created them (#3161)
Just in case, we should check to make sure that we created the
account-with-no-registrars before we delete it.
2026-07-17 16:10:34 +00:00
gbrodman 6b74924067 Handle too-large requests/responses (#3147)
100 MB is kind of arbitrary, but it's as good as any other limit.

D.1 numbers 1, 8, 9
2026-07-17 16:10:20 +00:00
gbrodman 71e9bc95a7 Use cache for RDAP searches for host by superord domain (#3145)
this allows us to only do one query instead of looping over the hosts
and doing queries one by one, while still leveraging the cache.
2026-07-17 14:33:48 +00:00
gbrodman bf54a0d0c4 Add index to rlock email address (#3150)
We sometimes query by this. The table is very small but eh, just in case
2026-07-17 03:47:53 +00:00
gbrodman 2a04b9be9b Reload SINGLE_USE ATs to avoid cache race conditions (#3157) 2026-07-17 03:47:48 +00:00
gbrodman 675354ae65 Add cloud scheduler task to sync remote caches (#3158) 2026-07-16 19:14:26 +00:00
gbrodman 21421726e0 Update security checks around registry lock verification (#3137)
1. make it a POST instead of a GET
2. pass the user into DomainLockUtils so we can check permissions to
   make sure they have permissions on the registrar
3. update all the tests
2026-07-16 17:12:34 +00:00
Ben McIlwain 192a7e6c4e Implement Expiry Access Period flows and billing (#3131)
This commit implements the second stage (Java ORM mappings and EPP flow
enforcement) of the Expiry Access Period (XAP) launch and opt-in mechanism,
completing the Two-PR deployment split mandated by db/README.md after the
Stage 1 database migrations (PR #3134) are live.

During XAP, a TLD charges a fee for domain registration during a timed period
after deletion. To prevent accidental charges, registrars must explicitly
opt in to participate in XAP registrations.

Specifically, this commit:
- Adds expiryAccessPeriodTransitions (a TimedTransitionProperty of
  ExpiryAccessPeriodMode) to Tld.java and ExpiryAccessPeriodModeTransitionUserType
  for mapping XAP mode schedules to PostgreSQL hstore columns.
- Adds expiryAccessPeriodEnabled to Registrar.java with getter, builder
  setter, and JSON map serialization, and regenerates db-schema.sql.generated.
- Enforces registrar opt-in in DomainCheckFlow: when an unallocated domain is
  in XAP and the querying registrar has not opted in, domain:check returns
  avail="0" with reason "Reserved".
- Enforces registrar opt-in in DomainCreateFlow: when attempting to register
  a domain in XAP without registrar opt-in, throws DomainReservedException
  (EPP error 2304 "Object status prohibits operation").
- Enforces explicit fee acknowledgment during XAP domain creation when the XAP
  fee is non-zero, throwing FeesRequiredDuringExpiryAccessPeriodException in
  DomainFlowUtils if omitted.
- Creates a one-time BillingEvent with Reason.FEE_EXPIRY_ACCESS when a domain
  is created during XAP with a non-zero fee, and adds getXapCost() to
  FeesAndCredits.
- Updates all test TLD YAML configurations and adds comprehensive unit and
  integration tests across DomainCheckFlowTest, DomainCreateFlowTest,
  DomainPricingLogicTest, and RegistrarTest.

TAG=agy
CONV=f2488c74-8b4a-43f1-9c22-d1dddbdbb4e0
BUG=http://b/437398822
2026-07-15 21:25:40 +00:00
Weimin Yu 542084eb70 Avoid outputting garbage sequences in Nomulus CLI (#3156)
Lazy-initialize the terminal and LineReader in ShellCommand to prevent JLine from eagerly probing terminal capabilities during JCommander command-line startup.

During CLI startup, JCommander instantiates every command (including ShellCommand) to build the CLI's command map. Eager instantiation of the LineReader inside the ShellCommand constructor causes JLine to query the terminal cursor via Device Status Report (DSR) escape sequences. Standard commands (e.g. list_tlds) do not consume stdin, leaving these probing responses (such as ^[[71;1R and 1;1R) in the buffer to be leaked as garbage characters to standard output.

This change defers terminal and LineReader initialization until the shell's run() method is actually executed, while preserving the original constructor for unit tests.

BUG=http://b/534855218
2026-07-15 16:42:18 +00:00
Ben McIlwain 54e605fa27 Add Git boundary and PR sync rules to GEMINI.md (#3151)
An AI coding agent (Jetski) repeatedly violated user instructions by executing
unsolicited remote updates (git push --force-with-lease origin ...) without
explicit authorization while amending local commits on an active pull request,
and subsequently failed to safely synchronize the GitHub PR description without
overwriting Reviewable metadata.

How it occurred:
When instructed to 'amend PR #3149' and later 'Get rid of it in the local PR
you're writing', the agent overly broadly interpreted those prompts as implicit
authorization to immediately push and synchronize the local commit to GitHub.
Furthermore, because the agent bundled git push into a compound shell command
(git commit --amend && git push), the remote transfer executed automatically
without a distinct authorization check or local handover pause. Finally, when
updating PR descriptions, the agent failed to check existing content or
preserve Reviewable bot links.

How this commit structurally prevents recurrence:
1. Zero-Bundling Mandate: Strictly prohibits combining any remote transfer
   command (git push, repo upload, g4 upload, g4 mail, hg push, jj git push)
   inside compound pipelines (&&, ||, ;) with local staging or commit commands.
   Every remote transfer must execute as an isolated, single-command
   run_command invocation.
2. Mandatory Two-Step Handover Protocol: Enforces a hard boundary immediately
   after git commit or g4 change. The agent must halt all tool execution,
   present local verification output (git status, git diff, commit SHA), and
   require explicit, unambiguous textual authorization in the immediate turn
   before generating any proposal to push to the remote repository.
3. Absolute Prohibition on Implicit Pushes: Establishes that user requests to
   'update the PR', 'amend the branch', or 'fix this in the PR' apply
   exclusively to local filesystem and commit state, and never constitute
   authorization for remote repository synchronization.
4. Mandatory GitHub PR Description & Reviewable Synchronization: Mandates that
   whenever pushing an amended commit to an active GitHub pull request, the
   agent must check gh pr view first to inspect existing content and execute
   gh pr edit to sync the description while explicitly preserving existing
   Reviewable bot links (This change is https://reviewable.io/reviews/...).
2026-07-15 14:45:09 +00:00
85 changed files with 2759 additions and 355 deletions
+9
View File
@@ -72,10 +72,19 @@ This document outlines foundational mandates, architectural patterns, and projec
- **No CodeSearch:** This project is hosted on GitHub, not Google3. Do NOT use `mcp_Coding_search_for_files_codesearch` or other internal Google3 search tools.
- **Local Grep:** Use local shell commands like `git grep` or `grep` via `run_shell_command` to search the codebase.
### 8. Git Operation Boundaries & Remote Push Prohibition
- **Zero-Bundling Rule for Remote Transfers:** NEVER bundle remote transfer commands (`git push`, `repo upload`, `g4 upload`, `g4 mail`, `hg push`, `jj git push`) inside compound bash pipelines (`&&`, `||`, `;`) with local staging or commit commands (e.g., `git commit --amend && git push ...`). Every remote transfer command MUST be executed as a standalone, single-command `run_command` invocation.
- **Mandatory Two-Step Handover Protocol:** When working on pull requests, changelists, or branches, strictly halt right at the local repository boundary (`git commit` / `g4 change`). Report the local commit SHA, `git status`, and `git diff` verification to the user, stop calling tools, and explicitly prompt: *"Local commit `[SHA]` is verified. Do you authorize running `git push origin [branch]` (or `--force-with-lease` when force-pushing an amended branch) to update the remote repository?"*
- **Absolute Prohibition on Unsolicited Pushes:** NEVER propose or run any remote synchronization or push command (`git push`, `repo upload`, `g4 upload`) on your own volition without explicit, unambiguous textual authorization in the immediate turn from the user.
- **Mandatory GitHub PR Description & Reviewable Synchronization:** Whenever pushing an amended commit to a branch associated with an active pull request, the agent MUST immediately synchronize the PR description on GitHub (`gh pr edit`). To do this safely:
1. Check `gh pr view --json body` first to inspect existing content.
2. Explicitly preserve any existing Reviewable bot links (`This change is https://reviewable.io/reviews/...`) when updating the body rather than blindly overwriting the entire description.
## Performance and Efficiency
- **Turn Minimization:** Aim for "perfect" code in the first iteration. Iterative fixes for checkstyle or compilation errors consume significant context and time.
- **Context Management:** Use sub-agents for batch refactoring or high-volume output tasks to keep the main session history lean and efficient.
- **Code Formatting:** Do not write custom Python scripts or manual regex replacements to fix code formatting issues (e.g., unused imports, import ordering, line length). Instead, use the project's built-in formatting tools: run `./gradlew spotlessApply` to fix unused/unordered imports and `./gradlew javaIncrementalFormatApply` (or `google-java-format --replace <files>`) to automatically fix Java formatting and indentation errors.
- **Programmatic Measurement for Markdown:** When writing Markdown files (`.md`)—and especially when formatting or line-wrapping them—you **MUST** explicitly use programmatic functions (such as `awk '{print length($0)}'` or Python `len(line)`) to measure the exact string length of every line from column 1, rather than making assumptions based on what appears in your context window.
## General Code Review Lessons & Avoidable Mistakes
Based on historical PR reviews, avoid the following common mistakes:
@@ -288,8 +288,9 @@ export class BackendService {
verifyRegistryLockRequest(
lockVerificationCode: string
): Observable<RegistryLockVerificationResponse> {
return this.http.get<RegistryLockVerificationResponse>(
`/console-api/registry-lock-verify?lockVerificationCode=${lockVerificationCode}`
return this.http.post<RegistryLockVerificationResponse>(
`/console-api/registry-lock-verify?lockVerificationCode=${lockVerificationCode}`,
{}
);
}
@@ -16,6 +16,7 @@ package google.registry.config;
import static com.google.common.base.Suppliers.memoize;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
import static google.registry.config.ConfigUtils.makeUrl;
import static google.registry.util.DateTimeUtils.START_INSTANT;
@@ -49,6 +50,7 @@ import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URL;
import java.time.DayOfWeek;
@@ -59,6 +61,7 @@ import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.joda.money.CurrencyUnit;
/**
* Central clearing-house for all configuration.
@@ -1110,6 +1113,40 @@ public final class RegistryConfig {
return config.registryPolicy.registryAdminClientId;
}
/** Returns the total length of the Expiry Access Period. */
@Provides
@Config("domainExpiryAccessPeriodTotalLength")
public static Duration provideDomainExpiryAccessPeriodTotalLength(
RegistryConfigSettings config) {
return Duration.ofSeconds(config.registryPolicy.domainExpiryAccessPeriod.totalLengthSeconds);
}
/** Returns the length of each tier of the Expiry Access Period. */
@Provides
@Config("domainExpiryAccessPeriodTierLength")
public static Duration provideDomainExpiryAccessPeriodTierLength(
RegistryConfigSettings config) {
return Duration.ofSeconds(config.registryPolicy.domainExpiryAccessPeriod.tierLengthSeconds);
}
/** Returns a map of the fee for the first tier of the Expiry Access Period, by currency. */
@Provides
@Config("domainExpiryAccessPeriodInitialFee")
public static ImmutableMap<CurrencyUnit, BigDecimal> provideDomainExpiryAccessPeriodInitialFee(
RegistryConfigSettings config) {
return config.registryPolicy.domainExpiryAccessPeriod.initialFee.entrySet().stream()
.collect(toImmutableMap(entry -> CurrencyUnit.of(entry.getKey()), Entry::getValue));
}
/** Returns a map of the fee for the last tier of the Expiry Access Period, by currency. */
@Provides
@Config("domainExpiryAccessPeriodFinalFee")
public static ImmutableMap<CurrencyUnit, BigDecimal> provideDomainExpiryAccessPeriodFinalFee(
RegistryConfigSettings config) {
return config.registryPolicy.domainExpiryAccessPeriod.finalFee.entrySet().stream()
.collect(toImmutableMap(entry -> CurrencyUnit.of(entry.getKey()), Entry::getValue));
}
@Singleton
@Provides
static RegistryConfigSettings provideRegistryConfigSettings() {
@@ -14,6 +14,7 @@
package google.registry.config;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -94,6 +95,7 @@ public class RegistryConfigSettings {
public String tmchCrlUrl;
public String tmchMarksDbUrl;
public String registryAdminClientId;
public DomainExpiryAccessPeriod domainExpiryAccessPeriod;
public String premiumTermsExportDisclaimer;
public String reservedTermsExportDisclaimer;
public String rdapTos;
@@ -106,6 +108,13 @@ public class RegistryConfigSettings {
public Set<String> noPollMessageOnDeletionRegistrarIds;
}
public static class DomainExpiryAccessPeriod {
public long totalLengthSeconds;
public long tierLengthSeconds;
public Map<String, BigDecimal> initialFee;
public Map<String, BigDecimal> finalFee;
}
/** Configuration for Hibernate. */
public static class Hibernate {
public boolean allowNestedTransactions;
@@ -88,6 +88,28 @@ registryPolicy:
# registrar
registryAdminClientId: TheRegistrar
# Configuration for the Expiry Access Period (XAP) that occurs immediately
# following completion of a domain's deletion. This needs to be enabled on a
# per-TLD basis as well.
domainExpiryAccessPeriod:
# The length of the total expiry access period; defaults to 10 days.
totalLengthSeconds: 864000
# The length of each tier during the expiry access period; defaults to 1
# hour. The XAP fee remains constant for the duration of each tier. The
# total length must be evenly divisible by the tier length, i.e. there
# should be a whole number of tiers all the same length.
tierLengthSeconds: 3600
# The initial fee for the first tier when the expiry access period starts,
# specified for each currency this registry platform uses.
initialFee:
USD: 100000
JPY: 10000000
# The final fee for the last tier when the expiry access period ends,
# specified for each currency this registry platform uses.
finalFee:
USD: 10
JPY: 1000
# Disclaimer at the top of the exported premium terms list.
premiumTermsExportDisclaimer: |
This list contains domains for the TLD offered at a premium price. This
@@ -323,4 +323,14 @@
<schedule>*/5 * * * *</schedule>
</task>
<task>
<url><![CDATA[/_dr/task/syncRemoteCache]]></url>
<name>syncRemoteCache</name>
<description>
Syncs remote (Valkey/Redis) EPP resource caches with changes made recently.
</description>
<!-- Runs every 5 minutes. RDAP needs to be updated within 60 minutes of changes. -->
<schedule>*/5 * * * *</schedule>
</task>
</entries>
@@ -15,9 +15,9 @@
package google.registry.flows;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static google.registry.flows.domain.DomainFlowUtils.toLogSafeLabel;
import static java.util.Collections.EMPTY_LIST;
import com.google.common.base.Ascii;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
@@ -90,9 +90,7 @@ public class FlowReporter {
*/
private static Optional<String> extractTld(String domainName) {
int index = domainName.indexOf('.');
return index == -1
? Optional.empty()
: Optional.of(Ascii.toLowerCase(domainName.substring(index + 1)));
return index == -1 ? Optional.empty() : toLogSafeLabel(domainName.substring(index + 1));
}
/**
@@ -46,6 +46,7 @@ import google.registry.model.transfer.TransferStatus;
import google.registry.persistence.VKey;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@@ -100,8 +101,30 @@ public final class ResourceFlowUtils {
public static <R extends EppResource> void verifyResourceDoesNotExist(
Class<R> clazz, String targetId, Instant now, String registrarId) throws EppException {
Optional<R> resource = ForeignKeyUtils.loadResource(clazz, targetId, now);
verifyResourceDoesNotExist(clazz, targetId, now, Duration.ZERO, registrarId);
}
/**
* Verifies that a resource with the specified type and id did not exist at the specified time.
*
* <p>This will throw an exception if the resource exists with a deletionTime greater than the
* specified time (i.e. a registrar is attempting to create a duplicate domain, host, or contact).
* If the resource had existed within the specified lookback window, but is not active now, i.e.
* it is recently deleted, then don't throw an exception, but do return the recently deleted
* resource for further processing (this is used by the Expiry Access Period).
*
* <p>If there is no need to specially handle the situation of recently deleted resources, then
* simply pass a lookback duration of {@link Duration#ZERO}.
*/
public static <R extends EppResource> Optional<R> verifyResourceDoesNotExist(
Class<R> clazz, String targetId, Instant now, Duration lookback, String registrarId)
throws EppException {
Instant startOfWindow = now.minus(lookback);
Optional<R> resource = ForeignKeyUtils.loadResource(clazz, targetId, startOfWindow);
if (resource.isPresent()) {
if (resource.get().getDeletionTime().isBefore(now)) {
return resource;
}
// These are similar exceptions, but we can track them internally as log-based metrics.
if (Objects.equals(registrarId, resource.get().getPersistedCurrentSponsorRegistrarId())) {
throw new ResourceAlreadyExistsForThisClientException(targetId);
@@ -109,6 +132,7 @@ public final class ResourceFlowUtils {
throw new ResourceCreateContentionException(targetId);
}
}
return Optional.empty();
}
/** Check that the given AuthInfo is present for a resource being transferred. */
@@ -25,6 +25,7 @@ import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccoun
import static google.registry.flows.domain.DomainFlowUtils.getReservationTypes;
import static google.registry.flows.domain.DomainFlowUtils.handleFeeRequest;
import static google.registry.flows.domain.DomainFlowUtils.isAnchorTenant;
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
import static google.registry.flows.domain.DomainFlowUtils.isRegisterBsaCreate;
import static google.registry.flows.domain.DomainFlowUtils.isReserved;
import static google.registry.flows.domain.DomainFlowUtils.isValidReservedCreate;
@@ -75,6 +76,7 @@ import google.registry.model.eppoutput.CheckData.DomainCheck;
import google.registry.model.eppoutput.CheckData.DomainCheckData;
import google.registry.model.eppoutput.EppResponse;
import google.registry.model.eppoutput.EppResponse.ResponseExtension;
import google.registry.model.registrar.Registrar;
import google.registry.model.reporting.IcannReportingTypes.ActivityReportField;
import google.registry.model.tld.Tld;
import google.registry.model.tld.Tld.TldState;
@@ -82,6 +84,7 @@ import google.registry.model.tld.label.ReservationType;
import google.registry.persistence.VKey;
import google.registry.util.Clock;
import jakarta.inject.Inject;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.HashSet;
@@ -138,6 +141,10 @@ public final class DomainCheckFlow implements TransactionalFlow {
@Config("maxChecks")
int maxChecks;
@Inject
@Config("domainExpiryAccessPeriodTotalLength")
Duration domainExpiryAccessPeriodTotalLength;
@Inject @Superuser boolean isSuperuser;
@Inject Clock clock;
@Inject EppResponse.Builder responseBuilder;
@@ -249,7 +256,14 @@ public final class DomainCheckFlow implements TransactionalFlow {
return Optional.of(e.getMessage());
}
return getMessageForCheckWithToken(
idn, existingDomains, bsaBlockedDomainNames, tldStates, token);
idn, existingDomains, bsaBlockedDomainNames, tldStates, token, now);
}
private static Optional<Domain> loadDomainIfInXap(
String domainName, Instant now, Duration domainExpiryAccessPeriodTotalLength) {
return ForeignKeyUtils.loadResource(
Domain.class, domainName, now.minus(domainExpiryAccessPeriodTotalLength))
.filter(domain -> isDomainEligibleForXap(domain, Tld.get(domain.getTld()), now));
}
private Optional<String> getMessageForCheckWithToken(
@@ -257,10 +271,18 @@ public final class DomainCheckFlow implements TransactionalFlow {
ImmutableMap<String, VKey<Domain>> existingDomains,
ImmutableSet<InternetDomainName> bsaBlockedDomains,
ImmutableMap<String, TldState> tldStates,
Optional<AllocationToken> allocationToken) {
Optional<AllocationToken> allocationToken,
Instant now) {
if (existingDomains.containsKey(domainName.toString())) {
return Optional.of("In use");
}
Tld tld = Tld.get(domainName.parent().toString());
if (tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED
&& !Registrar.loadByRegistrarIdCached(registrarId).get().getExpiryAccessPeriodEnabled()
&& loadDomainIfInXap(domainName.toString(), now, domainExpiryAccessPeriodTotalLength)
.isPresent()) {
return Optional.of("Reserved");
}
TldState tldState = tldStates.get(domainName.parent().toString());
if (isReserved(domainName, START_DATE_SUNRISE.equals(tldState))) {
if (!isValidReservedCreate(domainName, allocationToken)
@@ -339,16 +361,27 @@ public final class DomainCheckFlow implements TransactionalFlow {
.build());
continue;
}
Optional<Domain> domainForFee = domain;
boolean isAvailable = availableDomains.contains(domainName);
if (isAvailable
&& feeCheckItem.getCommandName().equals(FeeQueryCommandExtensionItem.CommandName.CREATE)
&& tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED) {
Optional<Domain> recentlyDeletedDomain =
loadDomainIfInXap(domainName, now, domainExpiryAccessPeriodTotalLength);
if (recentlyDeletedDomain.isPresent()) {
domainForFee = recentlyDeletedDomain;
}
}
handleFeeRequest(
feeCheckItem,
builder,
domainNames.get(domainName),
domain,
domainForFee,
feeCheck.getCurrency(),
now,
pricingLogic,
token,
availableDomains.contains(domainName),
isAvailable,
recurrences.getOrDefault(domainName, null));
// In the case of a registrar that is running a tiered pricing promotion, we issue two
// responses for the CREATE fee check command: one (the default response) with the
@@ -18,6 +18,7 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static google.registry.dns.DnsUtils.requestDomainDnsRefresh;
import static google.registry.flows.FlowUtils.persistEntityChanges;
import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn;
import static google.registry.flows.ResourceFlowUtils.verifyResourceDoesNotExist;
import static google.registry.flows.domain.DomainFlowUtils.COLLISION_MESSAGE;
import static google.registry.flows.domain.DomainFlowUtils.checkAllowedAccessToTld;
import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccount;
@@ -25,6 +26,7 @@ import static google.registry.flows.domain.DomainFlowUtils.cloneAndLinkReference
import static google.registry.flows.domain.DomainFlowUtils.createFeeCreateResponse;
import static google.registry.flows.domain.DomainFlowUtils.getReservationTypes;
import static google.registry.flows.domain.DomainFlowUtils.isAnchorTenant;
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
import static google.registry.flows.domain.DomainFlowUtils.isReserved;
import static google.registry.flows.domain.DomainFlowUtils.isValidReservedCreate;
import static google.registry.flows.domain.DomainFlowUtils.validateCreateCommandContactsAndNameservers;
@@ -58,6 +60,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.net.InternetDomainName;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfig.Config;
import google.registry.flows.EppException;
import google.registry.flows.EppException.CommandUseErrorException;
import google.registry.flows.EppException.ParameterValuePolicyErrorException;
@@ -71,6 +74,7 @@ import google.registry.flows.custom.DomainCreateFlowCustomLogic;
import google.registry.flows.custom.DomainCreateFlowCustomLogic.BeforeResponseParameters;
import google.registry.flows.custom.DomainCreateFlowCustomLogic.BeforeResponseReturnData;
import google.registry.flows.custom.EntityChanges;
import google.registry.flows.domain.DomainFlowUtils.DomainReservedException;
import google.registry.flows.domain.DomainFlowUtils.RegistrantProhibitedException;
import google.registry.flows.domain.token.AllocationTokenFlowUtils;
import google.registry.flows.exceptions.ContactsProhibitedException;
@@ -107,6 +111,7 @@ import google.registry.model.eppoutput.EppResponse;
import google.registry.model.poll.PendingActionNotificationResponse.DomainPendingActionNotificationResponse;
import google.registry.model.poll.PollMessage;
import google.registry.model.poll.PollMessage.Autorenew;
import google.registry.model.registrar.Registrar;
import google.registry.model.reporting.DomainTransactionRecord;
import google.registry.model.reporting.DomainTransactionRecord.TransactionReportField;
import google.registry.model.reporting.HistoryEntry;
@@ -123,6 +128,7 @@ import jakarta.inject.Inject;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.joda.money.Money;
/**
* An EPP flow that creates a new domain resource.
@@ -219,6 +225,10 @@ public final class DomainCreateFlow implements MutatingFlow {
@Inject DomainPricingLogic pricingLogic;
@Inject DomainDeletionTimeCache domainDeletionTimeCache;
@Inject
@Config("domainExpiryAccessPeriodTotalLength")
Duration domainExpiryAccessPeriodTotalLength;
@Inject
DomainCreateFlow() {}
@@ -245,6 +255,13 @@ public final class DomainCreateFlow implements MutatingFlow {
InternetDomainName domainName = validateDomainName(command.getDomainName());
String domainLabel = domainName.parts().getFirst();
Tld tld = Tld.get(domainName.parent().toString());
Duration lookback =
tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED
? domainExpiryAccessPeriodTotalLength
: Duration.ZERO;
Optional<Domain> recentlyDeletedDomain =
verifyResourceDoesNotExist(Domain.class, targetId, now, lookback, registrarId)
.filter(domain -> isDomainEligibleForXap(domain, tld, now));
validateCreateCommandContactsAndNameservers(command, tld, domainName);
TldState tldState = tld.getTldState(now);
Optional<LaunchCreateExtension> launchCreate =
@@ -280,6 +297,10 @@ public final class DomainCreateFlow implements MutatingFlow {
if (!isSuperuser) {
checkAllowedAccessToTld(registrarId, tld.getTldStr());
checkHasBillingAccount(registrarId, tld.getTldStr());
if (recentlyDeletedDomain.isPresent()
&& !Registrar.loadByRegistrarIdCached(registrarId).get().getExpiryAccessPeriodEnabled()) {
throw new DomainReservedException(domainName.toString());
}
boolean isValidReservedCreate = isValidReservedCreate(domainName, allocationToken);
ClaimsList claimsList = ClaimsListDao.get();
verifyIsGaOrSpecialCase(
@@ -327,7 +348,14 @@ public final class DomainCreateFlow implements MutatingFlow {
eppInput.getSingleExtension(FeeCreateCommandExtension.class);
FeesAndCredits feesAndCredits =
pricingLogic.getCreatePrice(
tld, targetId, now, years, isAnchorTenant, isSunriseCreate, allocationToken);
tld,
targetId,
now,
recentlyDeletedDomain,
years,
isAnchorTenant,
isSunriseCreate,
allocationToken);
validateFeeChallenge(feeCreate, feesAndCredits, defaultTokenUsed);
Optional<SecDnsCreateExtension> secDnsCreate =
validateSecDnsExtension(eppInput.getSingleExtension(SecDnsCreateExtension.class));
@@ -358,7 +386,15 @@ public final class DomainCreateFlow implements MutatingFlow {
entitiesToInsert.add(createBillingEvent, autorenewBillingEvent, autorenewPollMessage);
// Bill for EAP cost, if any.
if (!feesAndCredits.getEapCost().isZero()) {
entitiesToInsert.add(createEapBillingEvent(feesAndCredits, createBillingEvent));
entitiesToInsert.add(
createAdditionalBillingEvent(
Reason.FEE_EARLY_ACCESS, feesAndCredits.getEapCost(), createBillingEvent));
}
// Bill for XAP cost, if any.
if (!feesAndCredits.getXapCost().isZero()) {
entitiesToInsert.add(
createAdditionalBillingEvent(
Reason.FEE_EXPIRY_ACCESS, feesAndCredits.getXapCost(), createBillingEvent));
}
ImmutableSet<ReservationType> reservationTypes = getReservationTypes(domainName);
@@ -434,7 +470,14 @@ public final class DomainCreateFlow implements MutatingFlow {
FeesAndCredits responseFeesAndCredits =
shouldShowDefaultPrice
? pricingLogic.getCreatePrice(
tld, targetId, now, years, isAnchorTenant, isSunriseCreate, Optional.empty())
tld,
targetId,
now,
recentlyDeletedDomain,
years,
isAnchorTenant,
isSunriseCreate,
Optional.empty())
: feesAndCredits;
BeforeResponseReturnData responseData =
@@ -656,14 +699,14 @@ public final class DomainCreateFlow implements MutatingFlow {
}
}
private static BillingEvent createEapBillingEvent(
FeesAndCredits feesAndCredits, BillingEvent createBillingEvent) {
private static BillingEvent createAdditionalBillingEvent(
Reason reason, Money cost, BillingEvent createBillingEvent) {
return new BillingEvent.Builder()
.setReason(Reason.FEE_EARLY_ACCESS)
.setReason(reason)
.setTargetId(createBillingEvent.getTargetId())
.setRegistrarId(createBillingEvent.getRegistrarId())
.setPeriodYears(1)
.setCost(feesAndCredits.getEapCost())
.setCost(cost)
.setEventTime(createBillingEvent.getEventTime())
.setBillingTime(createBillingEvent.getBillingTime())
.setFlags(createBillingEvent.getFlags())
@@ -14,6 +14,7 @@
package google.registry.flows.domain;
import static com.google.common.base.Ascii.toLowerCase;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Strings.emptyToNull;
@@ -175,6 +176,27 @@ public class DomainFlowUtils {
/** Maximum number of characters in a domain label, from RFC 2181. */
private static final int MAX_LABEL_SIZE = 63;
/// Given a tld name or domain label, returns a label that can be safely logged, or
/// `Optional.empty()` if `label` is null or blank.
///
/// If `label` contains disallowed characters, they are replaced with '-'. If the resulting
/// string has a valid length, it is returned as is; if not, the first {@link #MAX_LABEL_SIZE}
/// chars plus a suffix of `...` is returned.
///
/// This method is mainly for use by `FlowReporter#recordToLogs()` to ensure that the logs
/// can be safely and correctly queried by SQL queries. See b/534931586 for more information.
public static Optional<String> toLogSafeLabel(String label) {
if (label == null || label.isBlank()) {
return Optional.empty();
}
var newLabel = ALLOWED_CHARS.negate().replaceFrom(toLowerCase(label), '-');
if (newLabel.length() <= MAX_LABEL_SIZE) {
return Optional.of(newLabel);
} else {
return Optional.of(newLabel.substring(0, MAX_LABEL_SIZE) + "...");
}
}
/**
* Returns parsed version of {@code name} if domain name label follows our naming rules and is
* under one of the given allowed TLDs.
@@ -642,6 +664,7 @@ public class DomainFlowUtils {
tld,
domainNameString,
now,
domain,
years,
isAnchorTenant(domainName, allocationToken, Optional.empty()),
isSunrise,
@@ -765,6 +788,9 @@ public class DomainFlowUtils {
if (!feesAndCredits.getEapCost().isZero()) {
throw new FeesRequiredDuringEarlyAccessProgramException(feesAndCredits.getEapCost());
}
if (!feesAndCredits.getXapCost().isZero()) {
throw new FeesRequiredDuringExpiryAccessPeriodException(feesAndCredits.getXapCost());
}
if (feesAndCredits.getTotalCost().isZero() || !feesAndCredits.isFeeExtensionRequired()) {
return;
}
@@ -1168,6 +1194,32 @@ public class DomainFlowUtils {
.getResultList();
}
/**
* Returns true if the domain was deleted during its Add Grace Period (AGP).
*
* <p>Per policy, domains deleted during AGP are not subject to Expiry Access Period (XAP) fees or
* reservation restrictions upon subsequent re-registration.
*/
public static boolean wasDeletedDuringAddGracePeriod(Domain domain, Tld tld) {
if (domain.getCreationTime() == null || domain.getDeletionTime() == null) {
return false;
}
return !domain
.getDeletionTime()
.isAfter(domain.getCreationTime().plus(tld.getAddGracePeriodLength()));
}
/**
* Returns true if the domain was deleted before {@code now} and is eligible for Expiry Access
* Period (XAP) evaluation.
*/
public static boolean isDomainEligibleForXap(Domain domain, Tld tld, Instant now) {
if (domain.getDeletionTime() == null || !domain.getDeletionTime().isBefore(now)) {
return false;
}
return !wasDeletedDuringAddGracePeriod(domain, tld);
}
/** Resource linked to this domain does not exist. */
static class LinkedResourcesDoNotExistException extends ObjectDoesNotExistException {
public LinkedResourcesDoNotExistException(Class<?> type, ImmutableSet<String> resourceIds) {
@@ -1355,6 +1407,18 @@ public class DomainFlowUtils {
}
}
/** Fees must be explicitly acknowledged when creating domains during the Expiry Access Period. */
static class FeesRequiredDuringExpiryAccessPeriodException
extends RequiredParameterMissingException {
public FeesRequiredDuringExpiryAccessPeriodException(Money expectedFee) {
super(
"Fees must be explicitly acknowledged when creating domains "
+ "during the Expiry Access Period. The XAP fee is: "
+ expectedFee);
}
}
/** The 'grace-period', 'applied' and 'refundable' fields are disallowed by server policy. */
static class UnsupportedFeeAttributeException extends UnimplementedOptionException {
UnsupportedFeeAttributeException() {
@@ -15,13 +15,18 @@
package google.registry.flows.domain;
import static com.google.common.base.Preconditions.checkArgument;
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
import static google.registry.flows.domain.DomainFlowUtils.zeroInCurrency;
import static google.registry.flows.domain.token.AllocationTokenFlowUtils.discountTokenInvalidForPremiumName;
import static google.registry.pricing.PricingEngineProxy.getPricesForDomainName;
import static google.registry.util.PreconditionsUtils.checkArgumentPresent;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Range;
import com.google.common.flogger.FluentLogger;
import com.google.common.net.InternetDomainName;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfig.Config;
import google.registry.flows.EppException;
import google.registry.flows.custom.DomainPricingCustomLogic;
import google.registry.flows.custom.DomainPricingCustomLogic.CreatePriceParameters;
@@ -31,6 +36,7 @@ import google.registry.flows.custom.DomainPricingCustomLogic.TransferPriceParame
import google.registry.flows.custom.DomainPricingCustomLogic.UpdatePriceParameters;
import google.registry.model.billing.BillingBase.RenewalPriceBehavior;
import google.registry.model.billing.BillingRecurrence;
import google.registry.model.domain.Domain;
import google.registry.model.domain.fee.BaseFee;
import google.registry.model.domain.fee.BaseFee.FeeType;
import google.registry.model.domain.fee.Fee;
@@ -40,7 +46,9 @@ import google.registry.model.domain.token.AllocationToken.TokenBehavior;
import google.registry.model.pricing.PremiumPricingEngine.DomainPrices;
import google.registry.model.tld.Tld;
import jakarta.inject.Inject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import javax.annotation.Nullable;
@@ -54,11 +62,28 @@ import org.joda.money.Money;
*/
public final class DomainPricingLogic {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final DomainPricingCustomLogic customLogic;
private final Duration expiryAccessPeriodTotalLength;
private final Duration expiryAccessPeriodTierLength;
private final ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodInitialFee;
private final ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodFinalFee;
@Inject
public DomainPricingLogic(DomainPricingCustomLogic customLogic) {
public DomainPricingLogic(
DomainPricingCustomLogic customLogic,
@Config("domainExpiryAccessPeriodTotalLength") Duration expiryAccessPeriodTotalLength,
@Config("domainExpiryAccessPeriodTierLength") Duration expiryAccessPeriodTierLength,
@Config("domainExpiryAccessPeriodInitialFee")
ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodInitialFee,
@Config("domainExpiryAccessPeriodFinalFee")
ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodFinalFee) {
this.customLogic = customLogic;
this.expiryAccessPeriodTotalLength = expiryAccessPeriodTotalLength;
this.expiryAccessPeriodTierLength = expiryAccessPeriodTierLength;
this.expiryAccessPeriodInitialFee = expiryAccessPeriodInitialFee;
this.expiryAccessPeriodFinalFee = expiryAccessPeriodFinalFee;
}
/**
@@ -71,35 +96,23 @@ public final class DomainPricingLogic {
Tld tld,
String domainName,
Instant dateTime,
Optional<Domain> recentlyDeletedDomain,
int years,
boolean isAnchorTenant,
boolean isSunriseCreate,
Optional<AllocationToken> allocationToken)
throws EppException {
CurrencyUnit currency = tld.getCurrency();
BaseFee createFee;
// Domain create cost is always zero for anchor tenants
if (isAnchorTenant) {
createFee = Fee.create(zeroInCurrency(currency), FeeType.CREATE, false);
} else {
DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime);
if (allocationToken.isPresent()) {
// Handle any special NONPREMIUM / SPECIFIED cases configured in the token
domainPrices =
applyTokenToDomainPrices(domainPrices, tld, dateTime, years, allocationToken.get());
}
Money domainCreateCost =
getDomainCreateCostWithDiscount(domainPrices, years, allocationToken, tld);
// Apply a sunrise discount if configured and applicable
if (isSunriseCreate) {
domainCreateCost =
domainCreateCost.multipliedBy(
1.0d - RegistryConfig.getSunriseDomainCreateDiscount(), RoundingMode.HALF_EVEN);
}
createFee =
Fee.create(domainCreateCost.getAmount(), FeeType.CREATE, domainPrices.isPremium());
}
Fee createFee =
computeCreateFee(
tld,
domainName,
dateTime,
years,
isAnchorTenant,
isSunriseCreate,
allocationToken,
currency);
// Create fees for the cost and the EAP fee, if any.
Fee eapFee = tld.getEapFeeFor(dateTime);
@@ -109,6 +122,7 @@ public final class DomainPricingLogic {
if (!isAnchorTenant && !eapFee.hasZeroCost()) {
feesBuilder.addFeeOrCredit(eapFee);
}
maybeAddXapFee(tld, dateTime, recentlyDeletedDomain, currency, feesBuilder);
// Apply custom logic to the create fee, if any.
return customLogic.customizeCreatePrice(
@@ -121,6 +135,113 @@ public final class DomainPricingLogic {
.build());
}
private Fee computeCreateFee(
Tld tld,
String domainName,
Instant dateTime,
int years,
boolean isAnchorTenant,
boolean isSunriseCreate,
Optional<AllocationToken> allocationToken,
CurrencyUnit currency)
throws EppException {
// Domain create cost is always zero for anchor tenants
if (isAnchorTenant) {
return Fee.create(zeroInCurrency(currency), FeeType.CREATE, false);
}
DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime);
if (allocationToken.isPresent()) {
// Handle any special NONPREMIUM / SPECIFIED cases configured in the token
domainPrices =
applyTokenToDomainPrices(domainPrices, tld, dateTime, years, allocationToken.get());
}
Money domainCreateCost =
getDomainCreateCostWithDiscount(domainPrices, years, allocationToken, tld);
// Apply a sunrise discount if configured and applicable
if (isSunriseCreate) {
domainCreateCost =
domainCreateCost.multipliedBy(
1.0d - RegistryConfig.getSunriseDomainCreateDiscount(), RoundingMode.HALF_EVEN);
}
return Fee.create(domainCreateCost.getAmount(), FeeType.CREATE, domainPrices.isPremium());
}
private void maybeAddXapFee(
Tld tld,
Instant dateTime,
Optional<Domain> recentlyDeletedDomain,
CurrencyUnit currency,
FeesAndCredits.Builder feesBuilder) {
if (tld.getExpiryAccessPeriodModeAt(dateTime) == Tld.ExpiryAccessPeriodMode.ENABLED
&& recentlyDeletedDomain.isPresent()
&& isDomainEligibleForXap(recentlyDeletedDomain.get(), tld, dateTime)) {
Optional<Fee> xapFee =
getXapFeeFor(dateTime, recentlyDeletedDomain.get().getDeletionTime(), currency);
xapFee.ifPresent(
fee -> {
feesBuilder.addFeeOrCredit(fee);
if (!fee.hasZeroCost()) {
feesBuilder.setFeeExtensionRequired(true);
}
});
}
}
/**
* Calculates and returns the Expiry Access Fee for a recently deleted domain at the given time.
*/
Optional<Fee> getXapFeeFor(Instant dateTime, Instant deletionTime, CurrencyUnit currency) {
Duration elapsedTimeInXap = Duration.between(deletionTime, dateTime);
if (!expiryAccessPeriodInitialFee.containsKey(currency)
|| !expiryAccessPeriodFinalFee.containsKey(currency)) {
// If the XAP schedule hasn't been configured in YAML for the currency this TLD uses, log the
// error and then short-circuit return (to allow the EPP flow to continue normally).
logger.atSevere().log(
"Expiry Access Period configuration is lacking initial or final fees for currency %s.",
currency);
return Optional.empty();
}
// Determine which tier the current time falls into (0-indexed).
long tier = elapsedTimeInXap.toMillis() / expiryAccessPeriodTierLength.toMillis();
long numTiers =
expiryAccessPeriodTotalLength.toMillis() / expiryAccessPeriodTierLength.toMillis();
if (tier < 0 || tier >= numTiers) {
return Optional.empty();
}
// Calculate the parameters for the geometric sequence: XAP fee = initialFee * ratio ^ tier.
BigDecimal xapFee;
if (expiryAccessPeriodInitialFee.get(currency).signum() == 0 || numTiers <= 1 || tier == 0) {
xapFee =
expiryAccessPeriodInitialFee
.get(currency)
.setScale(currency.getDecimalPlaces(), RoundingMode.HALF_EVEN);
} else {
double base =
expiryAccessPeriodFinalFee.get(currency).doubleValue()
/ expiryAccessPeriodInitialFee.get(currency).doubleValue();
double exponent = 1.0 / (numTiers - 1.0);
BigDecimal ratio = BigDecimal.valueOf(Math.pow(base, exponent));
BigDecimal fee = expiryAccessPeriodInitialFee.get(currency).multiply(ratio.pow((int) tier));
xapFee = fee.setScale(currency.getDecimalPlaces(), RoundingMode.HALF_EVEN);
}
Range<Instant> validPeriod =
Range.closedOpen(
deletionTime.plus(expiryAccessPeriodTierLength.multipliedBy(tier)),
deletionTime.plus(expiryAccessPeriodTierLength.multipliedBy(tier + 1)));
return Optional.of(
Fee.create(
xapFee,
FeeType.XAP,
// An XAP fee does not count as premium -- it's a separate one-time fee, independent of
// which the domain is separately considered standard vs premium.
false,
validPeriod,
validPeriod.upperEndpoint()));
}
/** Returns a new renewal cost for the pricer. */
public FeesAndCredits getRenewPrice(
Tld tld,
@@ -77,6 +77,11 @@ public class FeesAndCredits extends ImmutableObject implements Buildable {
return getTotalCostForType(FeeType.EAP);
}
/** Returns the XAP cost for the event. */
public Money getXapCost() {
return getTotalCostForType(FeeType.XAP);
}
/** Returns the renew cost for the event. */
public Money getRenewCost() {
return getTotalCostForType(FeeType.RENEW);
@@ -242,7 +242,14 @@ public class AllocationTokenFlowUtils {
case CREATE ->
pricingLogic
.getCreatePrice(
tld, domainName, now, yearsForAction, false, false, Optional.of(token))
tld,
domainName,
now,
Optional.empty(),
yearsForAction,
false,
false,
Optional.of(token))
.getTotalCost()
.getAmount();
case RENEW ->
@@ -270,11 +277,14 @@ public class AllocationTokenFlowUtils {
return maybeTokenEntity.get();
}
maybeTokenEntity = AllocationToken.get(VKey.create(AllocationToken.class, token));
if (maybeTokenEntity.isEmpty()) {
throw new NonexistentAllocationTokenException();
VKey<AllocationToken> tokenKey = VKey.create(AllocationToken.class, token);
AllocationToken tokenEntity =
AllocationToken.get(tokenKey).orElseThrow(NonexistentAllocationTokenException::new);
if (tokenEntity.getTokenType().equals(AllocationToken.TokenType.SINGLE_USE)) {
// Reload the token to avoid possible cache race conditions where the token may have already
// been redeemed
tokenEntity = tm().loadByKey(tokenKey);
}
AllocationToken tokenEntity = maybeTokenEntity.get();
validateTokenEntity(tokenEntity, registrarId, domainName, now);
return tokenEntity;
}
@@ -21,6 +21,7 @@ import static google.registry.util.DateTimeUtils.parseInstant;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.KeyDeserializer;
@@ -38,6 +39,7 @@ import com.google.common.collect.ImmutableSortedSet;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import google.registry.model.common.TimedTransitionProperty;
import google.registry.model.domain.token.AllocationToken;
import google.registry.model.tld.Tld.ExpiryAccessPeriodMode;
import google.registry.model.tld.Tld.TldState;
import google.registry.persistence.VKey;
import java.io.IOException;
@@ -336,7 +338,8 @@ public class EntityYamlUtils {
@Override
public TimedTransitionProperty<TldState> deserialize(
JsonParser jp, DeserializationContext context) throws IOException {
SortedMap<String, String> valueMap = jp.readValueAs(SortedMap.class);
SortedMap<String, String> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
@@ -347,6 +350,37 @@ public class EntityYamlUtils {
}
}
/**
* A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link
* ExpiryAccessPeriodMode}.
*/
public static class TimedTransitionPropertyExpiryAccessPeriodModeDeserializer
extends StdDeserializer<TimedTransitionProperty<ExpiryAccessPeriodMode>> {
public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer() {
this(null);
}
public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer(
Class<TimedTransitionProperty<ExpiryAccessPeriodMode>> t) {
super(t);
}
@Override
public TimedTransitionProperty<ExpiryAccessPeriodMode> deserialize(
JsonParser jp, DeserializationContext context) throws IOException {
SortedMap<String, String> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
toImmutableSortedMap(
natural(),
key -> parseInstant(key),
key -> ExpiryAccessPeriodMode.valueOf(valueMap.get(key)))));
}
}
/** A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link Money}. */
public static class TimedTransitionPropertyMoneyDeserializer
extends StdDeserializer<TimedTransitionProperty<Money>> {
@@ -362,7 +396,8 @@ public class EntityYamlUtils {
@Override
public TimedTransitionProperty<Money> deserialize(JsonParser jp, DeserializationContext context)
throws IOException {
SortedMap<String, LinkedHashMap<String, Object>> valueMap = jp.readValueAs(SortedMap.class);
SortedMap<String, LinkedHashMap<String, Object>> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, LinkedHashMap<String, Object>>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
@@ -392,7 +427,8 @@ public class EntityYamlUtils {
@Override
public TimedTransitionProperty<FeatureStatus> deserialize(
JsonParser jp, DeserializationContext context) throws IOException {
SortedMap<String, String> valueMap = jp.readValueAs(SortedMap.class);
SortedMap<String, String> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
@@ -16,6 +16,7 @@ package google.registry.model;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static google.registry.config.RegistryConfig.getEppResourceCachingDuration;
import static google.registry.config.RegistryConfig.getEppResourceMaxCachedEntries;
import static google.registry.persistence.transaction.TransactionManagerFactory.replicaTm;
@@ -400,4 +401,24 @@ public final class ForeignKeyUtils {
.filter(e -> now.isBefore(e.getDeletionTime()))
.map(e -> e.cloneProjectedAtTime(now));
}
/**
* Loads the last created version of multiple {@link EppResource}s from the replica database by
* foreign keys, using a cache.
*
* <p>This method ignores the config setting for caching, and is reserved for use cases that can
* tolerate slightly stale data.
*/
@SuppressWarnings("unchecked")
public static <E extends EppResource> ImmutableMap<String, E> loadResourcesByCache(
Class<E> clazz, Collection<String> foreignKeys, Instant now) {
ImmutableSet<VKey<? extends EppResource>> vkeys =
foreignKeys.stream().map(fk -> VKey.create(clazz, fk)).collect(toImmutableSet());
return foreignKeyToResourceCache.getAll(vkeys).entrySet().stream()
.filter(e -> e.getValue().isPresent() && now.isBefore(e.getValue().get().getDeletionTime()))
.collect(
toImmutableMap(
e -> (String) e.getKey().getKey(),
e -> (E) e.getValue().get().cloneProjectedAtTime(now)));
}
}
@@ -48,6 +48,7 @@ public abstract class BillingBase extends ImmutableObject
@Deprecated // DO NOT USE THIS REASON. IT REMAINS BECAUSE OF HISTORICAL DATA. SEE b/31676071.
ERROR(false),
FEE_EARLY_ACCESS(true),
FEE_EXPIRY_ACCESS(true),
RENEW(true),
RESTORE(true),
SERVER_STATUS(false),
@@ -200,7 +200,7 @@ public class BillingEvent extends BillingBase {
checkState(
instance.reason.hasPeriodYears() == (instance.periodYears != null),
"Period years must be set if and only if reason is "
+ "CREATE, FEE_EARLY_ACCESS, RENEW, RESTORE or TRANSFER.");
+ "CREATE, FEE_EARLY_ACCESS, FEE_EXPIRY_ACCESS, RENEW, RESTORE or TRANSFER.");
}
checkState(
instance.getFlags().contains(Flag.SYNTHETIC) == (instance.syntheticCreationTime != null),
@@ -51,6 +51,10 @@ public abstract class BaseFee extends ImmutableObject {
public enum FeeType {
CREATE("create"),
EAP("Early Access Period, fee expires: %s", "Early Access Period"),
/**
* A one-time fee for registering a recently dropped domain, during the Expiry Access Period.
*/
XAP("Expiry Access Period, fee expires: %s", "Expiry Access Period"),
RENEW("renew"),
RESTORE("restore"),
/**
@@ -52,6 +52,7 @@ import google.registry.model.reporting.HistoryEntry.HistoryEntryId;
import google.registry.persistence.VKey;
import google.registry.persistence.WithVKey;
import google.registry.persistence.converter.AllocationTokenStatusTransitionUserType;
import google.registry.util.NonFinalForTesting;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.AttributeOverrides;
import jakarta.persistence.Column;
@@ -61,6 +62,7 @@ import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
@@ -374,28 +376,39 @@ public class AllocationToken extends UpdateAutoTimestampEntity implements Builda
return ALLOCATION_TOKENS_CACHE.getAll(keys);
}
/** A cache that loads the {@link AllocationToken} object for a given AllocationToken VKey. */
private static final LoadingCache<VKey<AllocationToken>, Optional<AllocationToken>>
ALLOCATION_TOKENS_CACHE =
CacheUtils.newCacheBuilder(getSingletonCacheRefreshDuration())
.build(
new CacheLoader<>() {
@Override
public Optional<AllocationToken> load(VKey<AllocationToken> key) {
return tm().reTransact(() -> tm().loadByKeyIfPresent(key));
}
@VisibleForTesting
public static void setCacheForTest(Optional<Duration> expiry) {
Duration effectiveExpiry = expiry.orElse(getSingletonCacheRefreshDuration());
ALLOCATION_TOKENS_CACHE = createAllocationTokensCache(effectiveExpiry);
}
@Override
public Map<? extends VKey<AllocationToken>, ? extends Optional<AllocationToken>>
loadAll(Set<? extends VKey<AllocationToken>> keys) {
return tm().reTransact(
() ->
keys.stream()
.collect(
toImmutableMap(
key -> key, key -> tm().loadByKeyIfPresent(key))));
}
});
/** A cache that loads the {@link AllocationToken} object for a given AllocationToken VKey. */
@NonFinalForTesting
private static LoadingCache<VKey<AllocationToken>, Optional<AllocationToken>>
ALLOCATION_TOKENS_CACHE = createAllocationTokensCache(getSingletonCacheRefreshDuration());
private static LoadingCache<VKey<AllocationToken>, Optional<AllocationToken>>
createAllocationTokensCache(Duration expiry) {
return CacheUtils.newCacheBuilder(expiry)
.build(
new CacheLoader<>() {
@Override
public Optional<AllocationToken> load(VKey<AllocationToken> key) {
return tm().reTransact(() -> tm().loadByKeyIfPresent(key));
}
@Override
public Map<? extends VKey<AllocationToken>, ? extends Optional<AllocationToken>>
loadAll(Set<? extends VKey<AllocationToken>> keys) {
return tm().reTransact(
() ->
keys.stream()
.collect(
toImmutableMap(
key -> key, key -> tm().loadByKeyIfPresent(key))));
}
});
}
@Override
public VKey<AllocationToken> createVKey() {
@@ -265,6 +265,18 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
@Column(nullable = false)
boolean blockPremiumNames;
/**
* Whether this registrar has opted into participating in the Expiry Access Period (XAP).
*
* <p>If this is false, any domain currently in XAP will appear as reserved/unavailable on checks
* and creates.
*
* <p>TODO(mcilwain): Drop the temporary database default for expiry_access_period_enabled in a
* subsequent schema migration once this code is deployed.
*/
@Column(nullable = false)
boolean expiryAccessPeriodEnabled;
// Authentication.
/** X.509 PEM client certificate(s) used to authenticate registrar to EPP service. */
@@ -560,6 +572,10 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
return blockPremiumNames;
}
public boolean getExpiryAccessPeriodEnabled() {
return expiryAccessPeriodEnabled;
}
public boolean getContactsRequireSyncing() {
return contactsRequireSyncing;
}
@@ -654,6 +670,7 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
.put("whoisServer", getWhoisServer())
.putListOfStrings("rdapBaseUrls", getRdapBaseUrls())
.put("blockPremiumNames", blockPremiumNames)
.put("expiryAccessPeriodEnabled", expiryAccessPeriodEnabled)
.put("url", url)
.put("icannReferralEmail", getIcannReferralEmail())
.put("driveFolderId", driveFolderId)
@@ -918,6 +935,11 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
return this;
}
public Builder setExpiryAccessPeriodEnabled(boolean expiryAccessPeriodEnabled) {
getInstance().expiryAccessPeriodEnabled = expiryAccessPeriodEnabled;
return this;
}
public Builder setUrl(String url) {
getInstance().url = url;
return this;
@@ -56,6 +56,7 @@ import google.registry.model.EntityYamlUtils.OptionalDurationSerializer;
import google.registry.model.EntityYamlUtils.OptionalStringSerializer;
import google.registry.model.EntityYamlUtils.SortedEnumSetSerializer;
import google.registry.model.EntityYamlUtils.SortedSetSerializer;
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyExpiryAccessPeriodModeDeserializer;
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyMoneyDeserializer;
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyTldStateDeserializer;
import google.registry.model.EntityYamlUtils.TokenVKeyListDeserializer;
@@ -76,6 +77,7 @@ import google.registry.persistence.EntityCallbacksListener.RecursivePostUpdate;
import google.registry.persistence.VKey;
import google.registry.persistence.converter.AllocationTokenVkeyListUserType;
import google.registry.persistence.converter.BillingCostTransitionUserType;
import google.registry.persistence.converter.ExpiryAccessPeriodModeTransitionUserType;
import google.registry.persistence.converter.TldStateTransitionUserType;
import google.registry.tldconfig.idn.IdnTableEnum;
import google.registry.util.Idn;
@@ -120,6 +122,8 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
public static final boolean DEFAULT_ESCROW_ENABLED = false;
public static final boolean DEFAULT_DNS_PAUSED = false;
public static final ExpiryAccessPeriodMode DEFAULT_EXPIRY_ACCESS_PERIOD_MODE =
ExpiryAccessPeriodMode.DISABLED;
public static final Duration DEFAULT_ADD_GRACE_PERIOD = Duration.ofDays(5);
public static final Duration DEFAULT_AUTO_RENEW_GRACE_PERIOD = Duration.ofDays(45);
public static final Duration DEFAULT_REDEMPTION_GRACE_PERIOD = Duration.ofDays(30);
@@ -197,6 +201,12 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
PDT
}
/** The modes an Expiry Access Period (XAP) can be in at any given point in time. */
public enum ExpiryAccessPeriodMode {
DISABLED,
ENABLED
}
/** Returns the TLD for a given TLD, throwing if none exists. */
public static Tld get(String tld) {
return CACHE.get(tld).orElseThrow(() -> new TldNotFoundException(tld));
@@ -426,6 +436,19 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
@Column(nullable = false)
boolean dnsPaused = DEFAULT_DNS_PAUSED;
/**
* Whether the Expiry Access Period following domain deletes is enabled for this TLD.
*
* <p>TODO(mcilwain): Once this Java code has been fully deployed to production, drop the
* temporary database-level DEFAULT constraint on the "expiry_access_period_transitions" column in
* the "Tld" table in a subsequent schema release.
*/
@Column(nullable = false, columnDefinition = "hstore")
@Type(ExpiryAccessPeriodModeTransitionUserType.class)
@JsonDeserialize(using = TimedTransitionPropertyExpiryAccessPeriodModeDeserializer.class)
TimedTransitionProperty<ExpiryAccessPeriodMode> expiryAccessPeriodTransitions =
TimedTransitionProperty.withInitialValue(DEFAULT_EXPIRY_ACCESS_PERIOD_MODE);
/**
* The length of the add grace period for this TLD.
*
@@ -634,6 +657,14 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
return dnsPaused;
}
public ExpiryAccessPeriodMode getExpiryAccessPeriodModeAt(Instant now) {
return expiryAccessPeriodTransitions.getValueAtTime(now);
}
public ImmutableSortedMap<Instant, ExpiryAccessPeriodMode> getExpiryAccessPeriodTransitions() {
return expiryAccessPeriodTransitions.toValueMap();
}
@Nullable
public String getDriveFolderId() {
return driveFolderId;
@@ -830,6 +861,7 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
createBillingCostTransitions.checkValidity();
renewBillingCostTransitions.checkValidity();
eapFeeSchedule.checkValidity();
expiryAccessPeriodTransitions.checkValidity();
// All costs must be in the expected currency.
checkArgumentNotNull(getCurrency(), "Currency must be set");
Predicate<Money> currencyCheck =
@@ -913,6 +945,13 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
return this;
}
public Builder setExpiryAccessPeriodTransitions(
ImmutableSortedMap<Instant, ExpiryAccessPeriodMode> transitionsMap) {
getInstance().expiryAccessPeriodTransitions =
TimedTransitionProperty.fromValueMap(transitionsMap);
return this;
}
public Builder setDriveFolderId(String driveFolderId) {
getInstance().driveFolderId = driveFolderId;
return this;
@@ -0,0 +1,33 @@
// Copyright 2026 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.persistence.converter;
import google.registry.model.common.TimedTransitionProperty;
import google.registry.model.tld.Tld.ExpiryAccessPeriodMode;
/** Hibernate custom type for {@link TimedTransitionProperty} of {@link ExpiryAccessPeriodMode}. */
public class ExpiryAccessPeriodModeTransitionUserType
extends TimedTransitionBaseUserType<ExpiryAccessPeriodMode> {
@Override
String valueToString(ExpiryAccessPeriodMode value) {
return value.name();
}
@Override
ExpiryAccessPeriodMode stringToValue(String string) {
return ExpiryAccessPeriodMode.valueOf(string);
}
}
@@ -454,9 +454,8 @@ public class RdapDomainSearchAction extends RdapSearchActionBase {
// We must break the query up into chunks, because the in operator is limited to 30 subqueries.
// Since it is possible for the same domain to show up more than once in our result list (if
// we do a wildcard nameserver search that returns multiple nameservers used by the same
// domain), we must create a set of resulting {@link Domain} objects. Use a sorted set,
// and fetch all domains, to make sure that we can return the first domains in alphabetical
// order.
// domain), we must create a set of resulting {@link Domain}s. Use a sorted set, fetch all
// domains, to make sure that we can return the first domains in alphabetical order.
ImmutableSortedSet.Builder<Domain> domainSetBuilder =
ImmutableSortedSet.orderedBy(Comparator.comparing(Domain::getDomainName));
int numHostKeysSearched = 0;
@@ -465,7 +464,7 @@ public class RdapDomainSearchAction extends RdapSearchActionBase {
replicaTm()
.transact(
() -> {
for (VKey<Host> hostKey : hostKeys) {
for (VKey<Host> hostKey : chunk) {
CriteriaQueryBuilder<Domain> queryBuilder =
CriteriaQueryBuilder.create(replicaTm(), Domain.class)
.whereFieldContains("nsHosts", hostKey)
@@ -185,7 +185,7 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase {
throw new UnprocessableEntityException(
"A suffix after a wildcard in a nameserver lookup must be an in-bailiwick domain");
}
List<Host> hostList = new ArrayList<>();
List<String> matchingFqhns = new ArrayList<>();
for (String fqhn : ImmutableSortedSet.copyOf(domain.get().getSubordinateHosts())) {
if (cursorString.isPresent() && (fqhn.compareTo(cursorString.get()) <= 0)) {
continue;
@@ -193,10 +193,22 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase {
// We can't just check that the host name starts with the initial query string, because
// then the query ns.exam*.example.com would match against nameserver ns.example.com.
if (partialStringQuery.matches(fqhn)) {
Optional<Host> host =
ForeignKeyUtils.loadResourceByCache(Host.class, fqhn, getRequestTime());
if (shouldBeVisible(host)) {
hostList.add(host.get());
matchingFqhns.add(fqhn);
}
}
List<Host> hostList = new ArrayList<>();
int chunkSize = getStandardQuerySizeLimit();
// Batch load from cache in chunks to avoid sequential N+1 database queries on cache misses.
for (List<String> fqhnChunk : Iterables.partition(matchingFqhns, chunkSize)) {
if (hostList.size() > rdapResultSetMaxSize) {
break;
}
ImmutableMap<String, Host> cachedHosts =
ForeignKeyUtils.loadResourcesByCache(Host.class, fqhnChunk, getRequestTime());
for (String fqhn : fqhnChunk) {
Host host = cachedHosts.get(fqhn);
if (host != null && shouldBeVisible(host)) {
hostList.add(host);
if (hostList.size() > rdapResultSetMaxSize) {
break;
}
@@ -204,10 +216,7 @@ public class RdapNameserverSearchAction extends RdapSearchActionBase {
}
}
return makeSearchResults(
hostList,
IncompletenessWarningType.COMPLETE,
domain.get().getSubordinateHosts().size(),
CursorType.NAME);
hostList, IncompletenessWarningType.COMPLETE, hostList.size(), CursorType.NAME);
}
/**
@@ -119,6 +119,18 @@ public abstract class HttpException extends RuntimeException {
}
}
/** Exception that causes a 413 response. */
public static final class PayloadTooLargeException extends HttpException {
public PayloadTooLargeException(String message) {
super(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, message, null);
}
@Override
public String getResponseCodeString() {
return "Payload Too Large";
}
}
/** Exception that causes a 404 response. */
public static final class NotFoundException extends HttpException {
public NotFoundException() {
@@ -32,7 +32,6 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteStreams;
import com.google.common.io.CharStreams;
import com.google.common.net.MediaType;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
@@ -42,6 +41,7 @@ import com.google.protobuf.ByteString;
import dagger.Module;
import dagger.Provides;
import google.registry.request.HttpException.BadRequestException;
import google.registry.request.HttpException.PayloadTooLargeException;
import google.registry.request.HttpException.UnsupportedMediaTypeException;
import google.registry.request.auth.AuthResult;
import google.registry.request.lock.LockHandler;
@@ -50,6 +50,7 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.Map;
import java.util.Optional;
@@ -57,6 +58,8 @@ import java.util.Optional;
@Module
public final class RequestModule {
public static final int MAX_PAYLOAD_BYTES = 50 * 1024 * 1024;
private final HttpServletRequest req;
private final HttpServletResponse rsp;
private final AuthResult authResult;
@@ -167,9 +170,37 @@ public final class RequestModule {
@Provides
@Payload
static String providePayloadAsString(HttpServletRequest req) {
public static String providePayloadAsString(
@Payload byte[] payloadBytes, HttpServletRequest req) {
String charsetName = req.getCharacterEncoding();
Charset charset;
try {
return CharStreams.toString(req.getReader());
charset = (charsetName != null) ? Charset.forName(charsetName) : UTF_8;
} catch (IllegalArgumentException e) {
throw new UnsupportedMediaTypeException("Unsupported charset: " + charsetName, e);
}
return new String(payloadBytes, charset);
}
@Provides
@Payload
public static byte[] providePayloadAsBytes(HttpServletRequest req) {
try {
if (req.getContentLengthLong() > MAX_PAYLOAD_BYTES) {
throw new PayloadTooLargeException(
String.format(
"Payload size %d exceeds limit of %d bytes",
req.getContentLengthLong(), MAX_PAYLOAD_BYTES));
}
if (req.getInputStream() == null) {
return new byte[0];
}
byte[] bytes =
ByteStreams.toByteArray(ByteStreams.limit(req.getInputStream(), MAX_PAYLOAD_BYTES + 1));
if (bytes.length > MAX_PAYLOAD_BYTES) {
throw new PayloadTooLargeException("Payload exceeds maximum allowed size");
}
return bytes;
} catch (IOException e) {
throw new RuntimeException(e);
}
@@ -177,22 +208,8 @@ public final class RequestModule {
@Provides
@Payload
static byte[] providePayloadAsBytes(HttpServletRequest req) {
try {
return ByteStreams.toByteArray(req.getInputStream());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Provides
@Payload
static ByteString providePayloadAsByteString(HttpServletRequest req) {
try {
return ByteString.copyFrom(ByteStreams.toByteArray(req.getInputStream()));
} catch (IOException e) {
throw new RuntimeException(e);
}
public static ByteString providePayloadAsByteString(@Payload byte[] payloadBytes) {
return ByteString.copyFrom(payloadBytes);
}
@Provides
@@ -251,12 +268,10 @@ public final class RequestModule {
@Provides
@OptionalJsonPayload
public static Optional<JsonElement> provideJsonBody(HttpServletRequest req, Gson gson) {
try {
// GET requests return a null reader and thus a null JsonObject, which is fine
return Optional.ofNullable(gson.fromJson(req.getReader(), JsonElement.class));
} catch (IOException e) {
public static Optional<JsonElement> provideJsonBody(@Payload String payloadString, Gson gson) {
if (payloadString.isEmpty()) {
return Optional.empty();
}
return Optional.ofNullable(gson.fromJson(payloadString, JsonElement.class));
}
}
@@ -22,6 +22,7 @@ import static com.google.common.net.HttpHeaders.CONTENT_LENGTH;
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
import com.google.common.net.MediaType;
import java.io.ByteArrayInputStream;
@@ -37,6 +38,11 @@ import org.apache.commons.compress.utils.IOUtils;
/** Utilities for common functionality relating to {@link URLConnection}s. */
public final class UrlConnectionUtils {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
// 100 MB is arbitrary, not specifically selected for any reason
public static final int MAX_RESPONSE_PAYLOAD_BYTES = 100 * 1024 * 1024;
private UrlConnectionUtils() {}
/**
@@ -48,10 +54,26 @@ public final class UrlConnectionUtils {
* @see HttpURLConnection#getErrorStream()
*/
public static byte[] getResponseBytes(HttpURLConnection connection) throws IOException {
int responseCode = connection.getResponseCode();
try (InputStream is =
responseCode < 400 ? connection.getInputStream() : connection.getErrorStream()) {
return ByteStreams.toByteArray(is);
try {
if (connection.getContentLengthLong() > MAX_RESPONSE_PAYLOAD_BYTES) {
throw new IOException(
String.format(
"Response size %d exceeds limit of %d bytes",
connection.getContentLengthLong(), MAX_RESPONSE_PAYLOAD_BYTES));
} else if ((double) connection.getContentLengthLong() / MAX_RESPONSE_PAYLOAD_BYTES >= 0.9) {
logger.atSevere().log(
"Response from %s was within 90%% of the maximum response size", connection.getURL());
}
int responseCode = connection.getResponseCode();
try (InputStream is =
responseCode < 400 ? connection.getInputStream() : connection.getErrorStream()) {
byte[] bytes =
ByteStreams.toByteArray(ByteStreams.limit(is, MAX_RESPONSE_PAYLOAD_BYTES + 1));
if (bytes.length > MAX_RESPONSE_PAYLOAD_BYTES) {
throw new IOException("Response exceeds maximum allowed size");
}
return bytes;
}
} catch (NullPointerException e) {
return new byte[] {};
}
@@ -28,6 +28,9 @@ import google.registry.config.RegistryConfig.Config;
import google.registry.model.ForeignKeyUtils;
import google.registry.model.billing.BillingBase.Reason;
import google.registry.model.billing.BillingEvent;
import google.registry.model.console.ConsolePermission;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.model.domain.Domain;
import google.registry.model.domain.DomainHistory;
import google.registry.model.domain.RegistryLock;
@@ -100,16 +103,16 @@ public final class DomainLockUtils {
*
* <p>This assumes that the lock object / domain in question has a pending lock or unlock.
*/
public RegistryLock verifyVerificationCode(String verificationCode, boolean isAdmin) {
public RegistryLock verifyVerificationCode(String verificationCode, User user) {
RegistryLock result =
tm().transact(
() -> {
RegistryLock lock = getByVerificationCode(verificationCode);
if (lock.getLockCompletionTime().isEmpty()) {
return verifyAndApplyLock(lock, isAdmin);
return verifyAndApplyLock(lock, user);
} else if (lock.getUnlockRequestTime().isPresent()
&& lock.getUnlockCompletionTime().isEmpty()) {
return verifyAndApplyUnlock(lock, isAdmin);
return verifyAndApplyUnlock(lock, user);
} else {
throw new IllegalArgumentException(
String.format(
@@ -203,11 +206,18 @@ public final class DomainLockUtils {
countdown));
}
private RegistryLock verifyAndApplyLock(RegistryLock lock, boolean isAdmin) {
private RegistryLock verifyAndApplyLock(RegistryLock lock, User user) {
Instant now = tm().getTxTime();
checkArgument(
!lock.isLockRequestExpired(now), "The pending lock has expired; please try again");
UserRoles userRoles = user.getUserRoles();
boolean isAdmin = userRoles.isAdmin();
checkArgument(
userRoles.hasPermission(lock.getRegistrarId(), ConsolePermission.REGISTRY_LOCK),
"User %s does not have registry lock permission on registrar %s",
user.getEmailAddress(),
lock.getRegistrarId());
checkArgument(!lock.isSuperuser() || isAdmin, "Non-admin user cannot complete admin lock");
RegistryLock newLock =
@@ -217,13 +227,29 @@ public final class DomainLockUtils {
return newLock;
}
private RegistryLock verifyAndApplyUnlock(RegistryLock lock, boolean isAdmin) {
private RegistryLock verifyAndApplyUnlock(RegistryLock lock, User user) {
Instant now = tm().getTxTime();
checkArgument(
!lock.isUnlockRequestExpired(now), "The pending unlock has expired; please try again");
checkArgument(isAdmin || !lock.isSuperuser(), "Non-admin user cannot complete admin unlock");
UserRoles userRoles = user.getUserRoles();
boolean isAdmin = userRoles.isAdmin();
checkArgument(
userRoles.hasPermission(lock.getRegistrarId(), ConsolePermission.REGISTRY_LOCK),
"User %s does not have registry lock permission on registrar %s",
user.getEmailAddress(),
lock.getRegistrarId());
checkArgument(!lock.isSuperuser() || isAdmin, "Non-admin user cannot complete admin unlock");
// The pending unlock must still be the most recent RegistryLock for this domain. If a newer
// lock exists (e.g. an admin/superuser lock applied after this unlock was requested or a
// different unlock+relock), the verification code being redeemed is for a superseded row and
// must not be allowed to strip the lock statuses that the newer row applied.
Optional<RegistryLock> mostRecent = RegistryLockDao.getMostRecentByRepoId(lock.getRepoId());
checkArgument(
mostRecent.isPresent() && mostRecent.get().getRevisionId().equals(lock.getRevisionId()),
"The pending unlock on %s has been superseded; please request a new unlock",
lock.getDomainName());
RegistryLock newLock =
RegistryLockDao.save(lock.asBuilder().setUnlockCompletionTime(now).build());
removeLockStatuses(newLock, isAdmin, now);
@@ -91,10 +91,11 @@ public class ShellCommand implements Command {
* flags aren't available in the constructor, so we have to do it in the {@link #run} function.
*/
private final CommandRunner originalRunner;
private final LineReader lineReader;
private final Clock clock;
private LineReader lineReader;
private Terminal terminal;
private JCommander jcommanderForCompletions;
private String prompt = null;
@Parameter(
@@ -118,21 +119,22 @@ public class ShellCommand implements Command {
""")
boolean encapsulateOutput = false;
ShellCommand(CommandRunner runner) throws IOException {
this(TerminalBuilder.terminal(), new SystemClock(), runner);
prompt = "nom > ";
lineReader.variable(LineReader.HISTORY_FILE, Path.of(USER_HOME.value(), HISTORY_FILE));
ShellCommand(CommandRunner runner) {
this.originalRunner = runner;
this.clock = new SystemClock();
this.prompt = "nom > ";
}
ShellCommand(Terminal terminal, Clock clock, CommandRunner runner) {
this.originalRunner = runner;
this.terminal = terminal;
this.lineReader = LineReaderBuilder.builder().terminal(terminal).build();
this.clock = clock;
}
private void setPrompt(RegistryToolEnvironment environment, boolean alert) {
// Do not set the prompt in tests.
if (lineReader.getTerminal() instanceof DumbTerminal) {
if (lineReader == null || lineReader.getTerminal() instanceof DumbTerminal) {
return;
}
prompt =
@@ -143,7 +145,10 @@ public class ShellCommand implements Command {
}
public ShellCommand buildCompletions(JCommander jcommander) {
((LineReaderImpl) lineReader).setCompleter(new JCommanderCompleter(jcommander));
this.jcommanderForCompletions = jcommander;
if (lineReader != null) {
((LineReaderImpl) lineReader).setCompleter(new JCommanderCompleter(jcommander));
}
return this;
}
@@ -209,6 +214,19 @@ public class ShellCommand implements Command {
/** Run the shell until the user presses "Ctrl-D". */
@Override
public void run() {
if (lineReader == null) {
try {
this.terminal = TerminalBuilder.terminal();
this.lineReader = LineReaderBuilder.builder().terminal(terminal).build();
lineReader.variable(LineReader.HISTORY_FILE, Path.of(USER_HOME.value(), HISTORY_FILE));
if (jcommanderForCompletions != null) {
((LineReaderImpl) lineReader)
.setCompleter(new JCommanderCompleter(jcommanderForCompletions));
}
} catch (IOException e) {
throw new RuntimeException("Failed to initialize terminal", e);
}
}
// Wrap standard output and error if requested. We have to do so here in run because the flags
// haven't been processed in the constructor.
CommandRunner runner =
@@ -15,7 +15,7 @@
package google.registry.ui.server.console;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static google.registry.request.Action.Method.GET;
import static google.registry.request.Action.Method.POST;
import com.google.common.base.Ascii;
import com.google.gson.annotations.Expose;
@@ -34,7 +34,7 @@ import jakarta.servlet.http.HttpServletResponse;
@Action(
service = Service.CONSOLE,
path = ConsoleRegistryLockVerifyAction.PATH,
method = {GET},
method = {POST},
auth = Auth.AUTH_PUBLIC_LOGGED_IN)
public class ConsoleRegistryLockVerifyAction extends ConsoleApiAction {
@@ -54,9 +54,8 @@ public class ConsoleRegistryLockVerifyAction extends ConsoleApiAction {
}
@Override
protected void getHandler(User user) {
RegistryLock lock =
domainLockUtils.verifyVerificationCode(lockVerificationCode, user.getUserRoles().isAdmin());
protected void postHandler(User user) {
RegistryLock lock = domainLockUtils.verifyVerificationCode(lockVerificationCode, user);
RegistryLockAction action =
lock.getUnlockCompletionTime().isPresent()
? RegistryLockAction.UNLOCKED
@@ -65,20 +64,19 @@ public class ConsoleRegistryLockVerifyAction extends ConsoleApiAction {
new RegistryLockVerificationResponse(
Ascii.toLowerCase(action.toString()), lock.getDomainName(), lock.getRegistrarId());
tm().transact(
() -> {
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(
action == RegistryLockAction.LOCKED
? ConsoleUpdateHistory.Type.REGISTRY_LOCK
: ConsoleUpdateHistory.Type.REGISTRY_UNLOCK)
.setDescription(
String.format(
"%s%s%s",
lock.getRegistrarId(),
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
lockResponse)));
});
() ->
finishAndPersistConsoleUpdateHistory(
new ConsoleUpdateHistory.Builder()
.setType(
action == RegistryLockAction.LOCKED
? ConsoleUpdateHistory.Type.REGISTRY_LOCK
: ConsoleUpdateHistory.Type.REGISTRY_UNLOCK)
.setDescription(
String.format(
"%s%s%s",
lock.getRegistrarId(),
ConsoleUpdateHistory.DESCRIPTION_SEPARATOR,
lockResponse))));
consoleApiParams.response().setPayload(consoleApiParams.gson().toJson(lockResponse));
consoleApiParams.response().setStatus(HttpServletResponse.SC_OK);
}
@@ -33,6 +33,7 @@ import com.google.api.services.directory.model.UserName;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
import com.google.gson.annotations.Expose;
import google.registry.config.RegistryConfig.Config;
import google.registry.model.console.ConsolePermission;
@@ -58,6 +59,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
@@ -69,6 +71,7 @@ import javax.annotation.Nullable;
public class ConsoleUsersAction extends ConsoleApiAction {
static final String PATH = "/console-api/users";
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final int PASSWORD_LENGTH = 16;
private final String registrarId;
@@ -184,11 +187,20 @@ public class ConsoleUsersAction extends ConsoleApiAction {
// User has no registrars assigned
if (updatedUser.getUserRoles().getRegistrarRoles().isEmpty()) {
try {
directory.users().delete(email).execute();
} catch (IOException e) {
setFailedResponse("Failed to delete the user workspace account", SC_INTERNAL_SERVER_ERROR);
throw e;
// Only delete the Workspace account if runCreate() provably minted it. Addresses that
// reached this row via CLI create_user, OteAccountBuilder, or other means must survive
if (isConsoleMintedAddress(email)) {
try {
directory.users().delete(email).execute();
} catch (IOException e) {
setFailedResponse(
"Failed to delete the user workspace account", SC_INTERNAL_SERVER_ERROR);
throw e;
}
} else {
logger.atInfo().log(
"Skipping Workspace deletion for %s because the console did not mint the account",
email);
}
VKey<User> key = VKey.create(User.class, email);
@@ -368,6 +380,22 @@ public class ConsoleUsersAction extends ConsoleApiAction {
return true;
}
/**
* True iff {@code email} matches the exact shape produced by {@link #runCreate()} for the current
* {@code registrarId}: {@code <3 alnum>.<registrarId>@<gSuiteDomainName>}.
*
* <p>Addresses of any other shape were provisioned outside this action and should not be passed
* to {@code directory.users().delete()}.
*/
private boolean isConsoleMintedAddress(String email) {
return email.endsWith("@" + gSuiteDomainName)
&& Pattern.matches(
String.format(
"^[a-zA-Z0-9]{3}\\.%s@%s$",
Pattern.quote(registrarId), Pattern.quote(gSuiteDomainName)),
email);
}
public record UserData(
@Expose String emailAddress,
@Expose String registryLockEmailAddress,
@@ -21,15 +21,12 @@
-- "tld":"","tlds":["a.how"],"icannActivityReportField":"srs-dom-check"}
SELECT
-- Remove quotation marks from tld fields.
REGEXP_EXTRACT(tld, '^"(.*)"$') AS tld,
tld,
activityReportField AS metricName,
COUNT(*) AS count
FROM (
SELECT
-- TODO(b/32486667): Replace with JSON.parse() UDF when available for views
SPLIT(
REGEXP_EXTRACT(JSON_EXTRACT(json, '$.tlds'), r'^\[(.*)\]$')) AS tlds,
JSON_EXTRACT_STRING_ARRAY(json, '$.tlds') AS tlds,
JSON_EXTRACT_SCALAR(json,
'$.resourceType') AS resourceType,
JSON_EXTRACT_SCALAR(json,
@@ -43,10 +40,10 @@ FROM (
WHERE
STARTS_WITH(jsonPayload.message, "FLOW-LOG-SIGNATURE-METADATA")
AND _TABLE_SUFFIX BETWEEN '%FIRST_DAY_OF_MONTH%' AND '%LAST_DAY_OF_MONTH%')
) AS regexes
) AS json_parsed
JOIN
-- Unnest the JSON-parsed tlds.
UNNEST(regexes.tlds) AS tld
UNNEST(json_parsed.tlds) AS tld
-- Exclude cases that can't be tabulated correctly, where activityReportField
-- is null/empty, or TLD is null/empty despite being a domain flow.
WHERE
@@ -184,16 +184,22 @@ class FlowReporterTest {
@Test
void testRecordToLogs_metadata_invalidDomainName_stillGuessesTld() throws Exception {
var maxLenTld = "a".repeat(63);
var domainMaxLenTld = "a." + maxLenTld;
var domainTldTooLong = "a." + maxLenTld + "b";
when(flowReporter.eppInput.isDomainType()).thenReturn(true);
when(flowReporter.eppInput.getSingleTargetId()).thenReturn(Optional.of("<foo@bar.com>"));
when(flowReporter.eppInput.getTargetIds()).thenReturn(ImmutableList.of("<foo@bar.com>"));
when(flowReporter.eppInput.getTargetIds())
.thenReturn(ImmutableList.of("<foo@bar.com>", domainMaxLenTld, domainTldTooLong));
flowReporter.recordToLogs();
Map<String, Object> json =
parseJsonMap(findFirstLogMessageByPrefix(handler, "FLOW-LOG-SIGNATURE-METADATA: "));
assertThat(json).containsEntry("targetId", "<foo@bar.com>");
assertThat(json).containsEntry("targetIds", ImmutableList.of("<foo@bar.com>"));
assertThat(json).containsEntry("tld", "com>");
assertThat(json).containsEntry("tlds", ImmutableList.of("com>"));
assertThat(json)
.containsEntry(
"targetIds", ImmutableList.of("<foo@bar.com>", domainMaxLenTld, domainTldTooLong));
assertThat(json).containsEntry("tld", "com-");
assertThat(json).containsEntry("tlds", ImmutableList.of("com-", maxLenTld, maxLenTld + "..."));
}
@Test
@@ -49,6 +49,8 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Ordering;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfigSettings;
import google.registry.flows.EppException;
import google.registry.flows.FlowUtils.GenericXmlSyntaxErrorException;
import google.registry.flows.FlowUtils.NotLoggedInException;
@@ -95,6 +97,7 @@ import google.registry.model.tld.Tld.TldState;
import google.registry.model.tld.label.ReservedList;
import google.registry.testing.DatabaseHelper;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.joda.money.Money;
@@ -2447,6 +2450,283 @@ class DomainCheckFlowTest extends ResourceCheckFlowTestCase<DomainCheckFlow, Dom
runFlowAssertResponse(outputXml);
}
@Test
void testFeeExtension_xapLabel_std_v1() throws Exception {
createTld("tld");
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("110.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("110.00"));
try {
clock.setTo(Instant.parse("2009-01-06T00:00:00Z"));
Instant deletionTime = Instant.parse("2009-01-01T00:00:00Z");
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_fee_stdv1.xml");
runFlowAssertResponse(loadFile("domain_check_fee_xap_response.xml"));
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testCheck_xapLabel_notOptedIn_returnsReserved() throws Exception {
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(false, "example1.tld", "Reserved"),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_afterXapEnds_notOptedIn_returnsAvailable() throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), checking the domain should
// return available (avail="1") without XAP fees or reservation blocks for non-opted-in
// registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_afterXapEnds_optedIn_returnsAvailable() throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), checking the domain should
// return available (avail="1") without XAP fees for opted-in registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_beforeXapStarts_notOptedIn_returnsAvailable()
throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, checking the
// domain should return available (avail="1") without XAP fees or reservation blocks for
// non-opted-in registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_beforeXapStarts_optedIn_returnsAvailable() throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, checking the
// domain should return available (avail="1") without XAP fees for opted-in registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_agpDeletedRegularDomain_duringXap_returnsAvailable() throws Exception {
// A regular domain (registered without an XAP fee) deleted during its Add Grace Period (AGP) is
// exempt from XAP, returning available (avail="1") without reservation blocks or XAP fees when
// checked during active XAP on the TLD.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
clock.now().minus(Duration.ofHours(1)),
Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
DatabaseHelper.persistDomainAsDeleted(
DatabaseHelper.newDomain("example1.tld")
.asBuilder()
.setCreationTimeForTest(creationTime)
.build(),
deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_agpDeletedXapDomain_duringXap_returnsAvailable() throws Exception {
// A domain originally registered with an XAP fee deleted during its Add Grace Period (AGP) is
// exempt from XAP, returning available (avail="1") without reservation blocks or XAP fees when
// checked during active XAP on the TLD.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant creationTime = clock.now().minus(Duration.ofHours(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
DatabaseHelper.persistDomainAsDeleted(
DatabaseHelper.newDomain("example1.tld")
.asBuilder()
.setCreationTimeForTest(creationTime)
.build(),
deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_anchorTenantDeletedAfterStandardAgp_duringXap_returnsReserved() throws Exception {
// An anchor tenant domain deleted after standard AGP (5 days), e.g. on day 10 of its 30-day
// anchor tenant AGP, is subject to XAP fees and reservation blocks (not exempt as an AGP
// delete).
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setAddGracePeriodLength(Duration.ofDays(5))
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(10));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
DatabaseHelper.persistDomainAsDeleted(
DatabaseHelper.newDomain("example1.tld")
.asBuilder()
.setCreationTimeForTest(creationTime)
.build(),
deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(false, "example1.tld", "Reserved"),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
static AllocationToken setUpDefaultToken() {
return setUpDefaultToken("TheRegistrar");
}
@@ -77,6 +77,7 @@ import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Ordering;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfigSettings;
import google.registry.flows.EppException;
import google.registry.flows.EppException.UnimplementedExtensionException;
import google.registry.flows.EppRequestSource;
@@ -113,6 +114,7 @@ import google.registry.flows.domain.DomainFlowUtils.FeeDescriptionMultipleMatche
import google.registry.flows.domain.DomainFlowUtils.FeeDescriptionParseException;
import google.registry.flows.domain.DomainFlowUtils.FeesMismatchException;
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredDuringEarlyAccessProgramException;
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredDuringExpiryAccessPeriodException;
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredForPremiumNameException;
import google.registry.flows.domain.DomainFlowUtils.InvalidDsRecordException;
import google.registry.flows.domain.DomainFlowUtils.InvalidIdnDomainLabelException;
@@ -2688,6 +2690,427 @@ class DomainCreateFlowTest extends ResourceFlowTestCase<DomainCreateFlow, Domain
+ "during the Early Access Program. The EAP fee is: USD 100.00");
}
@Test
void testFailure_domainInXap_withoutFeeAck_throwsException() throws Exception {
// Creating a domain during an active XAP tier without acknowledging the XAP fee in the EPP fee
// extension should fail with FeesRequiredDuringExpiryAccessPeriodException.
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
try {
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
setXapForTld("tld", deletionTime);
Exception e =
assertThrows(FeesRequiredDuringExpiryAccessPeriodException.class, this::runFlow);
assertThat(e)
.hasMessageThat()
.isEqualTo(
"Fees must be explicitly acknowledged when creating domains "
+ "during the Expiry Access Period. The XAP fee is: USD 100.00");
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testFailure_domainInXap_registrarNotOptedIn_throwsDomainReservedException()
throws Exception {
// When XAP is enabled on the TLD and the domain is in its XAP window, a registrar that has not
// opted into XAP cannot register the domain and should receive a DomainReservedException.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
Exception e = assertThrows(DomainReservedException.class, this::runFlow);
assertThat(e).hasMessageThat().isEqualTo("example.tld is a reserved domain");
}
@Test
void testSuccess_domainInXap_optedIn_withFeeAck_chargesXapFee() throws Exception {
// When an opted-in registrar acknowledges the XAP fee during an active XAP tier, domain
// creation should succeed and persist a one-time XAP fee (Reason.FEE_EXPIRY_ACCESS) in
// addition to the standard domain create fee (Reason.CREATE) and autorenew recurrence
// (Reason.RENEW).
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
try {
persistHosts();
Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z");
setXapForTld("tld", deletionTime);
setEppInput(
"domain_create_eap_fee.xml",
new ImmutableMap.Builder<String, String>()
.putAll(FEE_STD_1_0_MAP)
.put("DESCRIPTION_1", "create")
.put("DESCRIPTION_2", "Expiry Access Period")
.build());
runFlowAssertResponse(loadFile("domain_create_response_xap_fee.xml"));
Domain domain = reloadResourceByForeignKey();
DomainHistory historyEntry = getHistoryEntries(domain, DomainHistory.class).get(0);
assertBillingEvents(
new BillingEvent.Builder()
.setReason(Reason.CREATE)
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setPeriodYears(2)
.setCost(Money.of(USD, 24))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("tld").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingEvent.Builder()
.setReason(Reason.FEE_EXPIRY_ACCESS)
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setPeriodYears(1)
.setCost(Money.of(USD, 100))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("tld").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingRecurrence.Builder()
.setReason(Reason.RENEW)
.setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setEventTime(domain.getRegistrationExpirationTime())
.setRecurrenceEndTime(END_INSTANT)
.setDomainHistory(historyEntry)
.build());
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testSuccess_premiumDomainInXap_optedIn_withFeeAck_chargesPremiumAndXapFees()
throws Exception {
// Creating a premium domain during active XAP should stack both fees, charging the premium
// domain create fee (Reason.CREATE) plus the one-time XAP tier fee (Reason.FEE_EXPIRY_ACCESS).
createTld("example");
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
try {
persistHosts();
setEppInput("domain_create_premium_xap.xml");
Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z");
setXapForTld("example", deletionTime);
runFlowAssertResponse(loadFile("domain_create_response_premium_xap.xml"));
Domain domain = reloadResourceByForeignKey();
DomainHistory historyEntry = getHistoryEntries(domain, DomainHistory.class).get(0);
assertBillingEvents(
new BillingEvent.Builder()
.setReason(Reason.CREATE)
.setTargetId("rich.example")
.setRegistrarId("TheRegistrar")
.setPeriodYears(2)
.setCost(Money.of(USD, 200))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("example").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingEvent.Builder()
.setReason(Reason.FEE_EXPIRY_ACCESS)
.setTargetId("rich.example")
.setRegistrarId("TheRegistrar")
.setPeriodYears(1)
.setCost(Money.of(USD, 100))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("example").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingRecurrence.Builder()
.setReason(Reason.RENEW)
.setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
.setTargetId("rich.example")
.setRegistrarId("TheRegistrar")
.setEventTime(domain.getRegistrationExpirationTime())
.setRecurrenceEndTime(END_INSTANT)
.setDomainHistory(historyEntry)
.build());
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testSuccess_recentlyDeletedDomain_afterXapEnds_notOptedIn_succeedsWithoutXapFee()
throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), the domain returns to
// standard pricing and can be registered without XAP fees or reservation blocks by
// non-opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_recentlyDeletedDomain_afterXapEnds_optedIn_succeedsWithoutXapFee()
throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), the domain returns to
// standard pricing and can be registered without XAP fees by opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_recentlyDeletedDomain_beforeXapStarts_notOptedIn_succeedsWithoutXapFee()
throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, the domain
// can be registered at standard pricing without XAP fees or reservation blocks by
// non-opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_recentlyDeletedDomain_beforeXapStarts_optedIn_succeedsWithoutXapFee()
throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, the domain
// can be registered at standard pricing without XAP fees by opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_agpDeletedRegularDomain_duringXap_succeedsWithoutXapFee() throws Exception {
// A regular domain (registered without an XAP fee) that was deleted during its Add Grace
// Period (AGP) is exempt from XAP, succeeding at standard pricing without reservation blocks
// or XAP fees when re-registered during active XAP on the TLD.
persistHosts();
Instant creationTime = clock.now().minus(Duration.ofDays(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
clock.now().minus(Duration.ofHours(1)),
Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_agpDeletedXapDomain_duringXap_succeedsWithoutXapFee() throws Exception {
// A domain originally registered with an XAP fee that was deleted during its Add Grace
// Period (AGP) is exempt from XAP upon subsequent re-registration, succeeding at standard
// pricing without reservation blocks or XAP fees when re-registered during active XAP on the
// TLD.
persistHosts();
Instant creationTime = clock.now().minus(Duration.ofHours(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
private void setXapForTld(String tldStr, Instant deletionTime) throws Exception {
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
Tld.get(tldStr)
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
}
private void setEapForTld(String tld) {
persistResource(
Tld.get(tld)
@@ -402,6 +402,36 @@ class DomainDeleteFlowTest extends ResourceFlowTestCase<DomainDeleteFlow, Domain
GracePeriodStatus.ADD, "domain_delete_response_fee.xml", FEE_STD_1_0_MAP);
}
@Test
void testSuccess_addGracePeriodDelete_doesNotRefundXapFee() throws Exception {
setUpSuccessfulTest();
BillingEvent createBillingEvent =
persistResource(createBillingEvent(Reason.CREATE, Money.of(USD, 123)));
BillingEvent xapBillingEvent =
persistResource(createBillingEvent(Reason.FEE_EXPIRY_ACCESS, Money.of(USD, 100)));
setUpGracePeriods(
GracePeriod.forBillingEvent(GracePeriodStatus.ADD, domain.getRepoId(), createBillingEvent));
assertPollMessages(createAutorenewPollMessage("TheRegistrar").build());
clock.advanceOneMilli();
runFlowAssertResponse(loadFile("domain_delete_response_fee.xml", FEE_STD_1_0_MAP));
assertThat(reloadResourceByForeignKey()).isNull();
DomainHistory historyEntryDomainDelete =
getOnlyHistoryEntryOfType(domain, DOMAIN_DELETE, DomainHistory.class);
assertBillingEvents(
createAutorenewBillingEvent("TheRegistrar").setRecurrenceEndTime(clock.now()).build(),
createBillingEvent,
xapBillingEvent,
new BillingCancellation.Builder()
.setReason(Reason.CREATE)
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setEventTime(clock.now())
.setBillingTime(plusDays(TIME_BEFORE_FLOW, 1))
.setBillingEvent(createBillingEvent.createVKey())
.setDomainHistory(historyEntryDomainDelete)
.build());
}
private void doSuccessfulTest_noAddGracePeriod(String responseFilename) throws Exception {
doSuccessfulTest_noAddGracePeriod(responseFilename, ImmutableMap.of());
}
@@ -30,12 +30,15 @@ import static google.registry.util.DateTimeUtils.END_INSTANT;
import static google.registry.util.DateTimeUtils.START_INSTANT;
import static google.registry.util.DateTimeUtils.minusHours;
import static google.registry.util.DateTimeUtils.plusHours;
import static org.joda.money.CurrencyUnit.JPY;
import static org.joda.money.CurrencyUnit.USD;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Range;
import google.registry.flows.EppException;
import google.registry.flows.HttpSessionMetadata;
import google.registry.flows.SessionMetadata;
@@ -58,8 +61,10 @@ import google.registry.testing.FakeClock;
import google.registry.testing.FakeHttpSession;
import google.registry.util.Clock;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.joda.money.CurrencyUnit;
import org.joda.money.Money;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -85,7 +90,12 @@ public class DomainPricingLogicTest {
createTld("example");
sessionMetadata = new HttpSessionMetadata(new FakeHttpSession());
domainPricingLogic =
new DomainPricingLogic(new DomainPricingCustomLogic(eppInput, sessionMetadata, null));
new DomainPricingLogic(
new DomainPricingCustomLogic(eppInput, sessionMetadata, null),
Duration.ofDays(10),
Duration.ofHours(1),
ImmutableMap.of(USD, new BigDecimal("100000.00"), JPY, new BigDecimal("10000000")),
ImmutableMap.of(USD, new BigDecimal("10.00"), JPY, new BigDecimal("1000")));
tld =
persistResource(
Tld.get("example")
@@ -146,7 +156,14 @@ public class DomainPricingLogicTest {
persistResource(Tld.get("sunrise").asBuilder().setTldStateTransitions(transitions).build());
assertThat(
domainPricingLogic.getCreatePrice(
sunriseTld, "domain.sunrise", clock.now(), 2, false, true, Optional.empty()))
sunriseTld,
"domain.sunrise",
clock.now(),
Optional.empty(),
2,
false,
true,
Optional.empty()))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -170,7 +187,14 @@ public class DomainPricingLogicTest {
.build());
assertThat(
domainPricingLogic.getCreatePrice(
tld, "default.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
tld,
"default.example",
clock.now(),
Optional.empty(),
1,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -195,7 +219,14 @@ public class DomainPricingLogicTest {
// 3 year create should be 5 (discount price) + 10*2 (regular price) = 25.
assertThat(
domainPricingLogic.getCreatePrice(
tld, "default.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
tld,
"default.example",
clock.now(),
Optional.empty(),
3,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -218,7 +249,14 @@ public class DomainPricingLogicTest {
.build());
assertThat(
domainPricingLogic.getCreatePrice(
tld, "default.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
tld,
"default.example",
clock.now(),
Optional.empty(),
1,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1006,7 +1044,14 @@ public class DomainPricingLogicTest {
.build());
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
1,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1015,7 +1060,14 @@ public class DomainPricingLogicTest {
// Two-year create should be 13 (standard price) + 100 (premium price), and it's premium
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
2,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1051,7 +1103,14 @@ public class DomainPricingLogicTest {
// are standard
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
2,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1060,7 +1119,14 @@ public class DomainPricingLogicTest {
// Similarly, 3 years should be 13 + 10 + 10
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
3,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1081,7 +1147,14 @@ public class DomainPricingLogicTest {
// Two-year create should be 100 (premium 1st year) plus 10 (nonpremium 2nd year)
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
2,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1090,7 +1163,14 @@ public class DomainPricingLogicTest {
// Similarly, 3 years should be 100 + 10 + 10
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
3,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1135,4 +1215,291 @@ public class DomainPricingLogicTest {
.getRenewCost())
.isEqualTo(Money.of(USD, 25));
}
@Test
void testGetXapFeeFor_tier0_startOfXap() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofMinutes(30));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee)
.hasValue(
Fee.create(
new BigDecimal("100000.00"),
Fee.FeeType.XAP,
false,
Range.closedOpen(deletionTime, deletionTime.plus(Duration.ofHours(1))),
deletionTime.plus(Duration.ofHours(1))));
}
@Test
void testGetXapFeeFor_tier1() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofHours(1)).plus(Duration.ofMinutes(15));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee)
.hasValue(
Fee.create(
new BigDecimal("96219.61"),
Fee.FeeType.XAP,
false,
Range.closedOpen(
deletionTime.plus(Duration.ofHours(1)), deletionTime.plus(Duration.ofHours(2))),
deletionTime.plus(Duration.ofHours(2))));
}
@Test
void testGetXapFeeFor_tier239_finalTier() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofHours(239)).plus(Duration.ofMinutes(30));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee)
.hasValue(
Fee.create(
new BigDecimal("10.00"),
Fee.FeeType.XAP,
false,
Range.closedOpen(
deletionTime.plus(Duration.ofHours(239)),
deletionTime.plus(Duration.ofHours(240))),
deletionTime.plus(Duration.ofHours(240))));
}
@Test
void testGetXapFeeFor_unconfiguredCurrency() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofMinutes(30));
Optional<Fee> xapFee =
domainPricingLogic.getXapFeeFor(checkTime, deletionTime, CurrencyUnit.EUR);
assertThat(xapFee).isEmpty();
}
@Test
void testGetCreatePrice_xapEnabled_includesXapFeeAndRequiresExtension() throws Exception {
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("96232.61")));
}
@Test
void testGetXapFeeFor_afterXapEnds_returnsEmpty() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofDays(10)).plus(Duration.ofMinutes(1));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee).isEmpty();
}
@Test
void testGetCreatePrice_agpDeletedRegularDomain_duringXap_noXapFee() throws Exception {
// A regular domain deleted during AGP is exempt from XAP fee evaluation upon subsequent
// creation during active XAP.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
clock.now().minus(Duration.ofHours(1)),
Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
}
@Test
void testGetCreatePrice_agpDeletedXapDomain_duringXap_noXapFee() throws Exception {
// A domain originally registered with an XAP fee deleted during AGP is exempt from XAP fee
// evaluation upon subsequent creation during active XAP.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant creationTime = clock.now().minus(Duration.ofHours(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
}
@Test
void testGetCreatePrice_anchorTenantDeletedAfterStandardAgp_duringXap_chargesXapFee()
throws Exception {
// An anchor tenant domain deleted after standard AGP (5 days), e.g. on day 10 of its 30-day
// anchor tenant AGP, is subject to XAP fees upon re-registration (not exempt as an AGP delete).
Tld xapTld =
persistResource(
tld.asBuilder()
.setAddGracePeriodLength(Duration.ofDays(5))
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(10));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
}
@Test
void testGetCreatePrice_premiumDomainInXap_chargesPremiumAndXapFees() throws Exception {
// Calculating create price for a recently deleted premium domain during active XAP should sum
// both the premium create fee and the one-time XAP tier fee.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("premium.example")
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"premium.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.hasAnyPremiumFees()).isTrue();
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
assertThat(feesAndCredits.getCreateCost()).isEqualTo(Money.of(USD, new BigDecimal("100.00")));
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("96319.61")));
}
@Test
void testGetCreatePrice_zeroXapFee_doesNotRequireExtension() throws Exception {
// When an XAP tier fee is $0.00, it is included in the fee items but does not require a fee
// extension acknowledgment from the registrar.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
DomainPricingLogic zeroFeePricingLogic =
new DomainPricingLogic(
new DomainPricingCustomLogic(eppInput, sessionMetadata, null),
Duration.ofDays(10),
Duration.ofHours(1),
ImmutableMap.of(USD, BigDecimal.ZERO),
ImmutableMap.of(USD, BigDecimal.ZERO));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
zeroFeePricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
}
}
@@ -22,6 +22,7 @@ import static google.registry.model.domain.token.AllocationToken.TokenStatus.VAL
import static google.registry.model.domain.token.AllocationToken.TokenType.DEFAULT_PROMO;
import static google.registry.model.domain.token.AllocationToken.TokenType.SINGLE_USE;
import static google.registry.model.domain.token.AllocationToken.TokenType.UNLIMITED_USE;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static google.registry.testing.DatabaseHelper.createTld;
import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.testing.EppExceptionSubject.assertAboutEppExceptions;
@@ -36,6 +37,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.net.InternetDomainName;
@@ -44,6 +46,7 @@ import google.registry.flows.custom.DomainPricingCustomLogic;
import google.registry.flows.domain.DomainPricingLogic;
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTokenNotInPromotionException;
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AllocationTokenNotValidForRegistrarException;
import google.registry.flows.domain.token.AllocationTokenFlowUtils.AlreadyRedeemedAllocationTokenException;
import google.registry.flows.domain.token.AllocationTokenFlowUtils.NonexistentAllocationTokenException;
import google.registry.model.domain.fee.FeeQueryCommandExtensionItem.CommandName;
import google.registry.model.domain.token.AllocationToken;
@@ -54,6 +57,8 @@ import google.registry.model.tld.Tld;
import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension;
import google.registry.testing.FakeClock;
import google.registry.testing.TestCacheExtension;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
@@ -69,11 +74,20 @@ class AllocationTokenFlowUtilsTest {
final JpaIntegrationTestExtension jpa =
new JpaTestExtensions.Builder().withClock(clock).buildIntegrationTestExtension();
@RegisterExtension
final TestCacheExtension testCacheExtension =
new TestCacheExtension.Builder().withAllocationTokenCache(Duration.ofMinutes(10)).build();
private final AllocationTokenExtension allocationTokenExtension =
mock(AllocationTokenExtension.class);
private final DomainPricingLogic domainPricingLogic =
new DomainPricingLogic(new DomainPricingCustomLogic(null, null, null));
new DomainPricingLogic(
new DomainPricingCustomLogic(null, null, null),
Duration.ofDays(30),
Duration.ofDays(1),
ImmutableMap.of(),
ImmutableMap.of());
private Tld tld;
@@ -124,8 +138,13 @@ class AllocationTokenFlowUtilsTest {
.build());
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
assertThat(
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
"TheRegistrar", "example.tld", clock.now(), Optional.of(allocationTokenExtension)))
tm().transact(
() ->
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
"TheRegistrar",
"example.tld",
clock.now(),
Optional.of(allocationTokenExtension))))
.hasValue(token);
}
@@ -141,15 +160,17 @@ class AllocationTokenFlowUtilsTest {
.build());
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
assertThat(
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
"TheRegistrar",
clock.now(),
Optional.of(allocationTokenExtension),
tld,
"example.tld",
CommandName.CREATE,
Optional.of(1),
domainPricingLogic))
tm().transact(
() ->
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
"TheRegistrar",
clock.now(),
Optional.of(allocationTokenExtension),
tld,
"example.tld",
CommandName.CREATE,
Optional.of(1),
domainPricingLogic)))
.hasValue(token);
}
@@ -181,15 +202,17 @@ class AllocationTokenFlowUtilsTest {
.build());
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
assertThat(
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
"TheRegistrar",
clock.now(),
Optional.of(allocationTokenExtension),
tld,
"example.tld",
CommandName.CREATE,
Optional.of(1),
domainPricingLogic))
tm().transact(
() ->
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
"TheRegistrar",
clock.now(),
Optional.of(allocationTokenExtension),
tld,
"example.tld",
CommandName.CREATE,
Optional.of(1),
domainPricingLogic)))
.hasValue(defaultToken);
}
@@ -261,6 +284,29 @@ class AllocationTokenFlowUtilsTest {
assertLoadTokenFromExtensionThrowsException(NonexistentAllocationTokenException.class);
}
@Test
void testFailure_loadFromExtension_alreadyRedeemedToken() {
persistResource(
singleUseTokenBuilder().setRedemptionHistoryId(new HistoryEntryId("repoId", 1L)).build());
assertLoadTokenFromExtensionThrowsException(AlreadyRedeemedAllocationTokenException.class);
}
@Test
void testFailure_loadFromExtension_singleUseTokenStaleCacheReloadsFromDatabase() {
AllocationToken token = persistResource(singleUseTokenBuilder().build());
assertThat(AllocationToken.get(token.createVKey())).hasValue(token);
tm().transact(
() ->
tm().put(
token
.asBuilder()
.setRedemptionHistoryId(new HistoryEntryId("repoId", 1L))
.build()));
// cache still returns the old un-redeemed token
assertThat(AllocationToken.get(token.createVKey()).get().isRedeemed()).isFalse();
assertLoadTokenFromExtensionThrowsException(AlreadyRedeemedAllocationTokenException.class);
}
@Test
void testFailure_tokenInvalidForRegistrar() {
persistResource(
@@ -334,18 +380,20 @@ class AllocationTokenFlowUtilsTest {
// Tokens tied to a domain should throw a catastrophic exception if used for a different domain
persistResource(singleUseTokenBuilder().setDomainName("someotherdomain.tld").build());
when(allocationTokenExtension.getAllocationToken()).thenReturn("tokeN");
assertThrows(
AllocationTokenFlowUtils.AllocationTokenNotValidForDomainException.class,
() ->
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
"TheRegistrar",
clock.now(),
Optional.of(allocationTokenExtension),
tld,
"example.tld",
CommandName.CREATE,
Optional.of(1),
domainPricingLogic));
tm().transact(
() ->
assertThrows(
AllocationTokenFlowUtils.AllocationTokenNotValidForDomainException.class,
() ->
AllocationTokenFlowUtils.loadTokenFromExtensionOrGetDefault(
"TheRegistrar",
clock.now(),
Optional.of(allocationTokenExtension),
tld,
"example.tld",
CommandName.CREATE,
Optional.of(1),
domainPricingLogic)));
}
@Test
@@ -451,17 +499,19 @@ class AllocationTokenFlowUtilsTest {
}
private void assertLoadTokenFromExtensionThrowsException(Class<? extends EppException> clazz) {
assertAboutEppExceptions()
.that(
assertThrows(
clazz,
() ->
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
"TheRegistrar",
"example.tld",
clock.now(),
Optional.of(allocationTokenExtension))))
.marshalsToXml();
tm().transact(
() ->
assertAboutEppExceptions()
.that(
assertThrows(
clazz,
() ->
AllocationTokenFlowUtils.loadAllocationTokenFromExtension(
"TheRegistrar",
"example.tld",
tm().getTxTime(),
Optional.of(allocationTokenExtension))))
.marshalsToXml());
}
private AllocationToken.Builder singleUseTokenBuilder() {
@@ -144,4 +144,17 @@ class ForeignKeyUtilsTest {
fakeClock.now()))
.containsExactlyEntriesIn(ImmutableMap.of("ns1.example.com", host1.createVKey()));
}
@Test
void testSuccess_loadResourcesByCache_skipsDeletedAndNonexistent() {
Host host1 = persistActiveHost("ns1.example.com");
Host host2 = persistActiveHost("ns2.example.com");
persistResource(host2.asBuilder().setDeletionTime(minusDays(fakeClock.now(), 1)).build());
assertThat(
ForeignKeyUtils.loadResourcesByCache(
Host.class,
ImmutableList.of("ns1.example.com", "ns2.example.com", "ns3.example.com"),
fakeClock.now()))
.containsExactlyEntriesIn(ImmutableMap.of("ns1.example.com", host1));
}
}
@@ -773,4 +773,20 @@ class RegistrarTest extends EntityTestCase {
assertThat(Registrar.loadByRegistrarId("registrar").toString())
.contains("allowedTlds=[bar, baz, foo, gon, tri]");
}
@Test
void testSuccess_expiryAccessPeriodEnabled() {
assertThat(registrar.getExpiryAccessPeriodEnabled()).isFalse();
persistResource(registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build());
assertThat(
Registrar.loadByRegistrarId("registrar").orElseThrow().getExpiryAccessPeriodEnabled())
.isTrue();
}
@Test
void testSuccess_toJsonMap_includesExpiryAccessPeriodEnabled() {
assertThat(registrar.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", false);
Registrar modified = registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build();
assertThat(modified.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", true);
}
}
@@ -746,6 +746,37 @@ public final class TldTest extends EntityTestCase {
.isEqualTo(new BigDecimal("50.00"));
}
@Test
void testExpiryAccessPeriodMode_undefined() {
assertThat(Tld.get("tld").getExpiryAccessPeriodModeAt(fakeClock.now()))
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
}
@Test
void testExpiryAccessPeriodMode_specified() {
Instant a = minusDays(fakeClock.now(), 1);
Instant b = plusDays(fakeClock.now(), 1);
Tld tld =
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
a,
Tld.ExpiryAccessPeriodMode.ENABLED,
b,
Tld.ExpiryAccessPeriodMode.DISABLED))
.build();
assertThat(tld.getExpiryAccessPeriodModeAt(fakeClock.now()))
.isEqualTo(Tld.ExpiryAccessPeriodMode.ENABLED);
assertThat(tld.getExpiryAccessPeriodModeAt(minusDays(fakeClock.now(), 2)))
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
assertThat(tld.getExpiryAccessPeriodModeAt(plusDays(fakeClock.now(), 2)))
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
}
@Test
void testFailure_eapFee_wrongCurrency() {
IllegalArgumentException thrown =
@@ -25,6 +25,7 @@ import static google.registry.util.DateTimeUtils.minusMonths;
import static google.registry.util.DateTimeUtils.minusYears;
import static org.mockito.Mockito.verify;
import google.registry.model.host.Host;
import google.registry.model.registrar.Registrar;
import google.registry.rdap.RdapMetrics.EndpointType;
import google.registry.rdap.RdapMetrics.SearchType;
@@ -111,13 +112,16 @@ class RdapNameserverActionTest extends RdapActionBaseTestCase<RdapNameserverActi
@Test
void testNameserver_tldTithHyphenOn3And4_works() {
createTld("zz--main-2166");
persistResource(makePunycodedHost("ns1.cat.zz--main-2166", "1.2.3.4", null, "TheRegistrar"));
Host host =
persistResource(
makePunycodedHost("ns1.cat.zz--main-2166", "1.2.3.4", null, "TheRegistrar"));
assertAboutJson()
.that(generateActualJson("ns1.cat.zz--main-2166"))
.isEqualTo(
addPermanentBoilerplateNotices(
jsonFileBuilder()
.addNameserver("ns1.cat.zz--main-2166", "ns1.cat.zz--main-2166", "F-ROID")
.addNameserver(
"ns1.cat.zz--main-2166", "ns1.cat.zz--main-2166", host.getRepoId())
.putAll("ADDRESSTYPE", "v4", "ADDRESS", "1.2.3.4", "STATUS", "active")
.load("rdap_host.json")));
assertThat(response.getStatus()).isEqualTo(200);
@@ -29,6 +29,7 @@ import static google.registry.util.DateTimeUtils.minusDays;
import static google.registry.util.DateTimeUtils.minusMonths;
import static google.registry.util.DateTimeUtils.minusYears;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.Mockito.clearInvocations;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
@@ -46,6 +47,7 @@ import google.registry.rdap.RdapSearchResults.IncompletenessWarningType;
import google.registry.testing.FakeResponse;
import google.registry.testing.FullFieldsTestEntityHelper;
import java.net.URLDecoder;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -428,7 +430,7 @@ class RdapNameserverSearchActionTest extends RdapSearchActionTestCase<RdapNamese
action.registrarParam = Optional.of("unicoderegistrar");
generateActualJsonWithName("ns*.cat.lol");
assertThat(response.getStatus()).isEqualTo(404);
verifyErrorMetrics(Optional.of(2L), 404);
verifyErrorMetrics(Optional.of(0L), 404);
}
@Test
@@ -445,6 +447,30 @@ class RdapNameserverSearchActionTest extends RdapSearchActionTestCase<RdapNamese
verifyMetrics(2);
}
@Test
void testNameMatch_star_cat_lol_usesForeignKeyCache() {
generateActualJsonWithName("*.cat.lol");
assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(2);
clearInvocations(rdapMetrics);
Instant newTransferTime = clock.now();
persistResource(hostNs1CatLol.asBuilder().setLastTransferTime(newTransferTime).build());
clock.advanceOneMilli();
action.response = new FakeResponse();
generateActualJsonWithName("*.cat.lol");
assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(2);
JsonObject searchResults =
parseJsonObject(response.getPayload())
.getAsJsonArray("nameserverSearchResults")
.get(0)
.getAsJsonObject();
assertThat(searchResults.toString()).doesNotContain(newTransferTime.toString());
}
@Test
void testNameMatch_star_cat_lol_found_sameRegistrarRequested() {
action.registrarParam = Optional.of("TheRegistrar");
@@ -458,7 +484,7 @@ class RdapNameserverSearchActionTest extends RdapSearchActionTestCase<RdapNamese
action.registrarParam = Optional.of("unicoderegistrar");
generateActualJsonWithName("*.cat.lol");
assertThat(response.getStatus()).isEqualTo(404);
verifyErrorMetrics(Optional.of(2L), 404);
verifyErrorMetrics(Optional.of(0L), 404);
}
@Test
@@ -521,7 +547,7 @@ class RdapNameserverSearchActionTest extends RdapSearchActionTestCase<RdapNamese
"rdap_truncated_hosts.json", "QUERY", "name=nsx*.cat.lol&cursor=bnN4NC5jYXQubG9s"));
assertThat(response.getStatus()).isEqualTo(200);
// When searching names, we look for additional matches, in case some are not visible.
verifyMetrics(9, IncompletenessWarningType.TRUNCATED);
verifyMetrics(5, IncompletenessWarningType.TRUNCATED);
}
@Test
@@ -536,7 +562,7 @@ class RdapNameserverSearchActionTest extends RdapSearchActionTestCase<RdapNamese
.putAll("ADDRESSTYPE", "v6", "ADDRESS", "bad:f00d:cafe::15:beef")
.load("rdap_host_linked.json")));
assertThat(response.getStatus()).isEqualTo(200);
verifyMetrics(2);
verifyMetrics(1);
}
@Test
@@ -15,14 +15,23 @@
package google.registry.request;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.request.RequestModule.provideJsonBody;
import static google.registry.request.RequestModule.provideJsonPayload;
import static google.registry.request.RequestModule.providePayloadAsBytes;
import static google.registry.request.RequestModule.providePayloadAsString;
import static google.registry.security.JsonHttpTestUtils.createServletInputStream;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.net.MediaType;
import com.google.gson.Gson;
import google.registry.request.HttpException.BadRequestException;
import google.registry.request.HttpException.PayloadTooLargeException;
import google.registry.request.HttpException.UnsupportedMediaTypeException;
import google.registry.tools.GsonUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
/** Unit tests for {@link RequestModule}. */
@@ -72,4 +81,46 @@ final class RequestModuleTest {
UnsupportedMediaTypeException.class,
() -> provideJsonPayload(MediaType.JSON_UTF_8.withParameter("omg", "handel"), "{}", GSON));
}
@Test
void testProvidePayloadAsBytes_contentLengthExceedsLimit_throws413() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContentLengthLong()).thenReturn((long) RequestModule.MAX_PAYLOAD_BYTES + 1);
PayloadTooLargeException thrown =
assertThrows(PayloadTooLargeException.class, () -> providePayloadAsBytes(req));
assertThat(thrown).hasMessageThat().contains("exceeds limit");
}
@Test
void testProvidePayloadAsBytes_streamExceedsLimit_throws413() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContentLengthLong()).thenReturn(-1L);
when(req.getInputStream())
.thenReturn(createServletInputStream(new byte[RequestModule.MAX_PAYLOAD_BYTES + 1]));
PayloadTooLargeException thrown =
assertThrows(PayloadTooLargeException.class, () -> providePayloadAsBytes(req));
assertThat(thrown).hasMessageThat().contains("exceeds maximum allowed size");
}
@Test
void testProvidePayloadAsString_invalidCharset_throws415() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getCharacterEncoding()).thenReturn("invalid-charset-name");
UnsupportedMediaTypeException thrown =
assertThrows(
UnsupportedMediaTypeException.class,
() -> providePayloadAsString("hello".getBytes(UTF_8), req));
assertThat(thrown).hasMessageThat().contains("Unsupported charset: invalid-charset-name");
}
@Test
void testProvideJsonBody_contentLengthExceedsLimit_throws413() {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContentLengthLong()).thenReturn((long) RequestModule.MAX_PAYLOAD_BYTES + 1);
PayloadTooLargeException thrown =
assertThrows(
PayloadTooLargeException.class,
() -> provideJsonBody(providePayloadAsString(providePayloadAsBytes(req), req), GSON));
assertThat(thrown).hasMessageThat().contains("exceeds limit");
}
}
@@ -31,6 +31,7 @@ import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
@@ -80,13 +81,13 @@ public class UrlConnectionUtilsTest {
"294");
String payload =
"""
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r
Content-Disposition: form-data; name="lol"; filename="cat"\r
Content-Type: text/csv; charset=utf-8\r
\r
The nice people at the store say hello. ()\r
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA--\r
""";
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\r
Content-Disposition: form-data; name="lol"; filename="cat"\r
Content-Type: text/csv; charset=utf-8\r
\r
The nice people at the store say hello. ()\r
--------------------------------AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA--\r
""";
verify(connection).setDoOutput(true);
verify(connection).getOutputStream();
assertThat(connectionOutputStream.toByteArray()).isEqualTo(payload.getBytes(UTF_8));
@@ -124,4 +125,27 @@ public class UrlConnectionUtilsTest {
when(connection.getResponseCode()).thenReturn(400);
assertThat(UrlConnectionUtils.getResponseBytes(connection)).isEmpty();
}
@Test
void testGetResponseBytes_contentLengthExceedsLimit_throwsException() {
HttpsURLConnection connection = mock(HttpsURLConnection.class);
when(connection.getContentLengthLong())
.thenReturn((long) UrlConnectionUtils.MAX_RESPONSE_PAYLOAD_BYTES + 1);
IOException thrown =
assertThrows(IOException.class, () -> UrlConnectionUtils.getResponseBytes(connection));
assertThat(thrown).hasMessageThat().contains("exceeds limit");
}
@Test
void testGetResponseBytes_streamExceedsLimit_throwsException() throws Exception {
HttpsURLConnection connection = mock(HttpsURLConnection.class);
when(connection.getContentLengthLong()).thenReturn(-1L);
when(connection.getResponseCode()).thenReturn(200);
when(connection.getInputStream())
.thenReturn(
new ByteArrayInputStream(new byte[UrlConnectionUtils.MAX_RESPONSE_PAYLOAD_BYTES + 1]));
IOException thrown =
assertThrows(IOException.class, () -> UrlConnectionUtils.getResponseBytes(connection));
assertThat(thrown).hasMessageThat().contains("exceeds maximum allowed size");
}
}
@@ -24,7 +24,10 @@ import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import google.registry.tools.GsonUtils;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.Map;
@@ -84,4 +87,32 @@ public final class JsonHttpTestUtils {
final StringWriter writer) {
return memoize(() -> getJsonResponse(writer));
}
public static ServletInputStream createServletInputStream(byte[] data) {
ByteArrayInputStream bais = new ByteArrayInputStream(data);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return bais.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {}
@Override
public int read() {
return bais.read();
}
@Override
public int read(byte[] b, int off, int len) {
return bais.read(b, off, len);
}
};
}
}
@@ -17,6 +17,7 @@ package google.registry.testing;
import com.google.common.collect.ImmutableList;
import google.registry.model.EppResource;
import google.registry.model.ForeignKeyUtils;
import google.registry.model.domain.token.AllocationToken;
import google.registry.model.tld.label.PremiumListDao;
import google.registry.model.tmch.ClaimsListDao;
import java.time.Duration;
@@ -80,6 +81,11 @@ public class TestCacheExtension implements BeforeEachCallback, AfterEachCallback
return this;
}
public Builder withAllocationTokenCache(Duration expiry) {
cacheHandlers.add(new TestCacheHandler(AllocationToken::setCacheForTest, expiry));
return this;
}
public TestCacheExtension build() {
return new TestCacheExtension(ImmutableList.copyOf(cacheHandlers));
}
@@ -31,10 +31,14 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.cloud.tasks.v2.HttpMethod;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import google.registry.batch.RelockDomainAction;
import google.registry.model.billing.BillingBase;
import google.registry.model.billing.BillingBase.Reason;
import google.registry.model.billing.BillingEvent;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.model.domain.Domain;
import google.registry.model.domain.DomainHistory;
import google.registry.model.domain.RegistryLock;
@@ -67,6 +71,21 @@ public final class DomainLockUtilsTest {
private static final String DOMAIN_NAME = "example.tld";
private static final String POC_ID = "marla.singer@example.com";
private static final User NON_ADMIN_USER =
new User.Builder()
.setEmailAddress("user@theregistrar.com")
.setUserRoles(
new UserRoles.Builder()
.setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT))
.build())
.build();
private static final User ADMIN_USER =
new User.Builder()
.setEmailAddress("admin@theregistrar.com")
.setUserRoles(new UserRoles.Builder().setIsAdmin(true).build())
.build();
private final FakeClock clock = new FakeClock(Instant.now());
private DomainLockUtils domainLockUtils;
private CloudTasksHelper cloudTasksHelper = new CloudTasksHelper(clock);
@@ -122,7 +141,7 @@ public final class DomainLockUtilsTest {
clock.advanceBy(Duration.ofDays(1));
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER);
verifyProperlyLockedDomain(false);
}
@@ -135,7 +154,7 @@ public final class DomainLockUtilsTest {
RegistryLock unlockRequest =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
domainLockUtils.verifyVerificationCode(unlockRequest.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(unlockRequest.getVerificationCode(), NON_ADMIN_USER);
assertThat(loadByEntity(domain).getStatusValues()).containsNoneIn(REGISTRY_LOCK_STATUSES);
}
@@ -143,7 +162,7 @@ public final class DomainLockUtilsTest {
void testSuccess_applyLockDomain() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER);
verifyProperlyLockedDomain(false);
}
@@ -153,7 +172,7 @@ public final class DomainLockUtilsTest {
RegistryLock unlock =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
domainLockUtils.verifyVerificationCode(unlock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(unlock.getVerificationCode(), NON_ADMIN_USER);
verifyProperlyUnlockedDomain(false);
}
@@ -161,7 +180,7 @@ public final class DomainLockUtilsTest {
void testSuccess_applyAdminLock_onlyHistoryEntry() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", null, true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), ADMIN_USER);
verifyProperlyLockedDomain(true);
}
@@ -169,11 +188,11 @@ public final class DomainLockUtilsTest {
void testSuccess_applyAdminUnlock_onlyHistoryEntry() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", null, true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), ADMIN_USER);
RegistryLock unlock =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", true, Optional.empty());
domainLockUtils.verifyVerificationCode(unlock.getVerificationCode(), true);
domainLockUtils.verifyVerificationCode(unlock.getVerificationCode(), ADMIN_USER);
verifyProperlyUnlockedDomain(true);
}
@@ -194,7 +213,7 @@ public final class DomainLockUtilsTest {
void testSuccess_administrativelyUnlock_nonAdmin() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER);
domainLockUtils.administrativelyApplyUnlock(
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
verifyProperlyUnlockedDomain(false);
@@ -204,7 +223,7 @@ public final class DomainLockUtilsTest {
void testSuccess_administrativelyUnlock_admin() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", null, true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), ADMIN_USER);
domainLockUtils.administrativelyApplyUnlock(
DOMAIN_NAME, "TheRegistrar", true, Optional.empty());
verifyProperlyUnlockedDomain(true);
@@ -218,7 +237,7 @@ public final class DomainLockUtilsTest {
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
RegistryLock newLock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
newLock = domainLockUtils.verifyVerificationCode(newLock.getVerificationCode(), false);
newLock = domainLockUtils.verifyVerificationCode(newLock.getVerificationCode(), NON_ADMIN_USER);
assertThat(
getRegistryLockByRevisionId(oldLock.getRevisionId()).get().getRelock().getRevisionId())
.isEqualTo(newLock.getRevisionId());
@@ -252,7 +271,7 @@ public final class DomainLockUtilsTest {
RegistryLock lock =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", false, Optional.of(Duration.ofHours(6)));
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER);
cloudTasksHelper.assertTasksEnqueued(
QUEUE_ASYNC_ACTIONS,
new TaskMatcher()
@@ -302,7 +321,7 @@ public final class DomainLockUtilsTest {
void testFailure_createUnlock_alreadyPendingUnlock() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER);
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
@@ -321,7 +340,7 @@ public final class DomainLockUtilsTest {
void testFailure_createUnlock_nonAdminUnlockingAdmin() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", null, true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), true);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), ADMIN_USER);
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
@@ -385,12 +404,13 @@ public final class DomainLockUtilsTest {
void testFailure_applyLock_alreadyApplied() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER);
domain = loadByEntity(domain);
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false));
() ->
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER));
assertThat(thrown)
.hasMessageThat()
.isEqualTo("Lock/unlock with code 123456789ABCDEFGHJKLMNPQRSTUVWXY is already completed");
@@ -405,7 +425,7 @@ public final class DomainLockUtilsTest {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), true));
() -> domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), ADMIN_USER));
assertThat(thrown).hasMessageThat().isEqualTo("The pending lock has expired; please try again");
assertNoDomainChanges();
}
@@ -417,31 +437,146 @@ public final class DomainLockUtilsTest {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false));
() ->
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER));
assertThat(thrown).hasMessageThat().isEqualTo("Non-admin user cannot complete admin lock");
assertNoDomainChanges();
}
@Test
void testFailure_applyLock_noPermission() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
User userWithoutPermission =
new User.Builder()
.setEmailAddress("unauthorized@example.com")
.setUserRoles(new UserRoles.Builder().build())
.build();
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
domainLockUtils.verifyVerificationCode(
lock.getVerificationCode(), userWithoutPermission));
assertThat(thrown)
.hasMessageThat()
.isEqualTo(
"User unauthorized@example.com does not have registry lock permission on registrar"
+ " TheRegistrar");
assertNoDomainChanges();
}
@Test
void testFailure_applyUnlock_noPermission() {
domainLockUtils.administrativelyApplyLock(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
RegistryLock unlock =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
User userWithoutPermission =
new User.Builder()
.setEmailAddress("unauthorized@example.com")
.setUserRoles(new UserRoles.Builder().build())
.build();
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
domainLockUtils.verifyVerificationCode(
unlock.getVerificationCode(), userWithoutPermission));
assertThat(thrown)
.hasMessageThat()
.isEqualTo(
"User unauthorized@example.com does not have registry lock permission on registrar"
+ " TheRegistrar");
}
@Test
void testFailure_applyUnlock_alreadyUnlocked() {
RegistryLock lock =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), NON_ADMIN_USER);
RegistryLock unlock =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
domainLockUtils.verifyVerificationCode(unlock.getVerificationCode(), false);
domainLockUtils.verifyVerificationCode(unlock.getVerificationCode(), NON_ADMIN_USER);
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> domainLockUtils.verifyVerificationCode(unlock.getVerificationCode(), false));
() ->
domainLockUtils.verifyVerificationCode(
unlock.getVerificationCode(), NON_ADMIN_USER));
assertThat(thrown)
.hasMessageThat()
.isEqualTo("Lock/unlock with code Zabcdefghijkmnopqrstuvwxyz123456 is already completed");
assertNoDomainChanges();
}
@Test
void testFailure_applyUnlock_supersededByNewerLock() {
domainLockUtils.administrativelyApplyLock(DOMAIN_NAME, "TheRegistrar", POC_ID, false);
RegistryLock unlockRequest =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", false, Optional.empty());
clock.advanceOneMilli();
domainLockUtils.administrativelyApplyLock(DOMAIN_NAME, "TheRegistrar", null, true);
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
domainLockUtils.verifyVerificationCode(
unlockRequest.getVerificationCode(), NON_ADMIN_USER));
assertThat(thrown)
.hasMessageThat()
.isEqualTo(
"The pending unlock on example.tld has been superseded; please request a new unlock");
}
@Test
void testFailure_applyUnlock_supersededByInterleavedUnlockAndLock() {
// Lock the domain initially
domainLockUtils.administrativelyApplyLock(DOMAIN_NAME, "TheRegistrar", null, true);
// 1. Unlock A requested
clock.advanceOneMilli();
RegistryLock unlockA =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", true, Optional.empty());
// 2. Unlock B requested
clock.advanceOneMilli();
RegistryLock unlockB =
domainLockUtils.saveNewRegistryUnlockRequest(
DOMAIN_NAME, "TheRegistrar", true, Optional.empty());
// 3. Unlock B completed
clock.advanceOneMilli();
domainLockUtils.verifyVerificationCode(unlockB.getVerificationCode(), ADMIN_USER);
// 4. Lock C requested
clock.advanceOneMilli();
RegistryLock lockC =
domainLockUtils.saveNewRegistryLockRequest(DOMAIN_NAME, "TheRegistrar", POC_ID, true);
// 5. Lock C applied
clock.advanceOneMilli();
domainLockUtils.verifyVerificationCode(lockC.getVerificationCode(), ADMIN_USER);
// 6. Unlock A completion attempt should fail since it has been superseded -- the second unlock
// request rewrote the verification code
clock.advanceOneMilli();
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() ->
domainLockUtils.verifyVerificationCode(unlockA.getVerificationCode(), ADMIN_USER));
assertThat(thrown)
.hasMessageThat()
.isEqualTo(
String.format("Invalid verification code \"%s\"", unlockA.getVerificationCode()));
}
@Test
void testFailure_applyLock_alreadyLocked() {
RegistryLock lock =
@@ -453,7 +588,7 @@ public final class DomainLockUtilsTest {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
() -> domainLockUtils.verifyVerificationCode(verificationCode, false));
() -> domainLockUtils.verifyVerificationCode(verificationCode, NON_ADMIN_USER));
assertThat(thrown).hasMessageThat().isEqualTo("Domain example.tld is already locked");
// Failure during the lock acquisition portion shouldn't affect the SQL object
@@ -27,6 +27,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import google.registry.model.console.User;
import google.registry.model.console.UserRoles;
import google.registry.model.domain.Domain;
import google.registry.model.domain.RegistryLock;
import google.registry.model.registrar.Registrar.Type;
@@ -56,11 +58,17 @@ class UnlockDomainCommandTest extends CommandTestCase<UnlockDomainCommand> {
command.printStream = System.out;
}
private static final User ADMIN_USER =
new User.Builder()
.setEmailAddress("admin@theregistrar.com")
.setUserRoles(new UserRoles.Builder().setIsAdmin(true).build())
.build();
private Domain persistLockedDomain(String domainName, String registrarId) {
Domain domain = persistResource(DatabaseHelper.newDomain(domainName));
RegistryLock lock =
command.domainLockUtils.saveNewRegistryLockRequest(domainName, registrarId, null, true);
command.domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), true);
command.domainLockUtils.verifyVerificationCode(lock.getVerificationCode(), ADMIN_USER);
return reloadResource(domain);
}
@@ -21,10 +21,13 @@ import com.google.gson.Gson;
import google.registry.model.console.User;
import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.request.auth.AuthResult;
import google.registry.security.JsonHttpTestUtils;
import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.FakeClock;
import google.registry.testing.FakeResponse;
import google.registry.tools.GsonUtils;
import jakarta.servlet.ServletInputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -51,4 +54,8 @@ public abstract class ConsoleActionBaseTestCase {
consoleApiParams = ConsoleApiParamsUtils.createFake(authResult);
response = (FakeResponse) consoleApiParams.response();
}
protected static ServletInputStream createServletInputStream(String data) {
return JsonHttpTestUtils.createServletInputStream(data.getBytes(StandardCharsets.UTF_8));
}
}
@@ -23,7 +23,6 @@ import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,9 +46,7 @@ import google.registry.ui.server.console.ConsoleEppPasswordAction.EppPasswordDat
import google.registry.util.EmailMessage;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -167,16 +164,13 @@ class ConsoleEppPasswordActionTest extends ConsoleActionBaseTestCase {
AuthenticatedRegistrarAccessor.createForTesting(
ImmutableSetMultimap.of("TheRegistrar", OWNER));
when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
doReturn(
new BufferedReader(
new StringReader(
String.format(
eppPostData, registrarId, oldPassword, newPassword, newPasswordRepeat))))
.when(consoleApiParams.request())
.getReader();
Optional<EppPasswordData> maybePasswordChangeRequest =
ConsoleModule.provideEppPasswordChangeRequest(
GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON));
GSON,
RequestModule.provideJsonBody(
String.format(
eppPostData, registrarId, oldPassword, newPassword, newPasswordRepeat),
GSON));
return new ConsoleEppPasswordAction(
consoleApiParams, authenticatedRegistrarAccessor, maybePasswordChangeRequest);
@@ -182,6 +182,20 @@ public class ConsoleRegistryLockVerifyActionTest extends ConsoleActionBaseTestCa
.containsAtLeastElementsIn(REGISTRY_LOCK_STATUSES);
}
@Test
void testFailure_noPermission() {
saveRegistryLock(createDefaultLockBuilder().build());
user = user.asBuilder().setUserRoles(new UserRoles.Builder().build()).build();
action = createAction(DEFAULT_CODE);
action.run();
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_BAD_REQUEST);
assertThat(response.getPayload())
.isEqualTo(
"User user@theregistrar.com does not have registry lock permission on registrar"
+ " TheRegistrar");
assertThat(loadByEntity(defaultDomain).getStatusValues()).containsExactly(StatusValue.INACTIVE);
}
private RegistryLock.Builder createDefaultLockBuilder() {
return new RegistryLock.Builder()
.setRepoId(defaultDomain.getRepoId())
@@ -194,7 +208,7 @@ public class ConsoleRegistryLockVerifyActionTest extends ConsoleActionBaseTestCa
private ConsoleRegistryLockVerifyAction createAction(String verificationCode) {
AuthResult authResult = AuthResult.createUser(user);
ConsoleApiParams params = ConsoleApiParamsUtils.createFake(authResult);
when(params.request().getMethod()).thenReturn("GET");
when(params.request().getMethod()).thenReturn("POST");
when(params.request().getServerName()).thenReturn("registrarconsole.tld");
DomainLockUtils domainLockUtils =
new DomainLockUtils(
@@ -21,7 +21,6 @@ import static google.registry.testing.DatabaseHelper.loadSingleton;
import static google.registry.testing.DatabaseHelper.persistResource;
import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -43,9 +42,7 @@ import google.registry.util.EmailMessage;
import google.registry.util.RegistryEnvironment;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
@@ -219,12 +216,8 @@ class ConsoleUpdateRegistrarActionTest extends ConsoleActionBaseTestCase {
private ConsoleUpdateRegistrarAction createAction(String requestData) throws IOException {
when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
doReturn(new BufferedReader(new StringReader(requestData)))
.when(consoleApiParams.request())
.getReader();
Optional<Registrar> maybeRegistrarUpdateData =
ConsoleModule.provideRegistrar(
GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON));
ConsoleModule.provideRegistrar(GSON, RequestModule.provideJsonBody(requestData, GSON));
return new ConsoleUpdateRegistrarAction(consoleApiParams, maybeRegistrarUpdateData);
}
}
@@ -20,7 +20,10 @@ import static jakarta.servlet.http.HttpServletResponse.SC_CREATED;
import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.google.api.services.directory.Directory;
@@ -243,7 +246,8 @@ class ConsoleUsersActionTest extends ConsoleActionBaseTestCase {
}
@Test
void testSuccess_deletesUser() throws IOException {
void testSuccess_deletesUser_nonConsoleMintedAddress_skipsWorkspaceAccountDeletion()
throws IOException {
User user1 = DatabaseHelper.loadByKey(VKey.create(User.class, "test1@test.com"));
AuthResult authResult =
AuthResult.createUser(
@@ -265,6 +269,44 @@ class ConsoleUsersActionTest extends ConsoleActionBaseTestCase {
assertThat(response.getStatus()).isEqualTo(SC_OK);
assertThat(DatabaseHelper.loadByKeyIfPresent(VKey.create(User.class, "test2@test.com")))
.isEmpty();
verify(users, never()).delete(anyString());
}
@Test
void testSuccess_deletesUser_consoleMintedAddress_deletesWorkspaceAccount() throws IOException {
User user1 = DatabaseHelper.loadByKey(VKey.create(User.class, "test1@test.com"));
AuthResult authResult =
AuthResult.createUser(
user1
.asBuilder()
.setUserRoles(user1.getUserRoles().asBuilder().setIsAdmin(true).build())
.build());
String mintedEmail = "abc.TheRegistrar@email.com";
DatabaseHelper.persistResource(
new User.Builder()
.setEmailAddress(mintedEmail)
.setUserRoles(
new UserRoles()
.asBuilder()
.setRegistrarRoles(
ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT))
.build())
.build());
ConsoleUsersAction action =
createAction(
Optional.of(ConsoleApiParamsUtils.createFake(authResult)),
Optional.of("DELETE"),
Optional.of(
new UserData(mintedEmail, null, RegistrarRole.ACCOUNT_MANAGER.toString(), null)));
action.cloudTasksUtils = cloudTasksHelper.getTestCloudTasksUtils();
when(directory.users()).thenReturn(users);
when(users.delete(mintedEmail)).thenReturn(delete);
action.run();
assertThat(response.getStatus()).isEqualTo(SC_OK);
assertThat(DatabaseHelper.loadByKeyIfPresent(VKey.create(User.class, mintedEmail))).isEmpty();
verify(users).delete(mintedEmail);
verify(delete).execute();
}
@Test
@@ -23,7 +23,6 @@ import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.testing.SqlHelper.saveRegistrar;
import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
@@ -42,9 +41,6 @@ import google.registry.testing.ConsoleApiParamsUtils;
import google.registry.testing.DeterministicStringGenerator;
import google.registry.testing.FakeResponse;
import google.registry.util.StringGenerator;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -222,20 +218,9 @@ class RegistrarsActionTest extends ConsoleActionBaseTestCase {
return new RegistrarsAction(
consoleApiParams, Optional.ofNullable(null), passwordGenerator, passcodeGenerator);
} else {
try {
doReturn(new BufferedReader(new StringReader(registrarParamMap.toString())))
.when(consoleApiParams.request())
.getReader();
} catch (IOException e) {
return new RegistrarsAction(
consoleApiParams,
Optional.ofNullable(null),
passwordGenerator,
passcodeGenerator);
}
Optional<Registrar> maybeRegistrar =
ConsoleModule.provideRegistrar(
GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON));
GSON, RequestModule.provideJsonBody(registrarParamMap.toString(), GSON));
return new RegistrarsAction(
consoleApiParams, maybeRegistrar, passwordGenerator, passcodeGenerator);
}
@@ -19,7 +19,6 @@ import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableO
import static google.registry.testing.DatabaseHelper.loadSingleton;
import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
@@ -41,9 +40,7 @@ import google.registry.testing.DatabaseHelper;
import google.registry.testing.FakeResponse;
import google.registry.ui.server.console.ConsoleActionBaseTestCase;
import google.registry.ui.server.console.ConsoleModule;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.HashMap;
import org.junit.jupiter.api.Test;
@@ -143,13 +140,10 @@ public class RdapRegistrarFieldsActionTest extends ConsoleActionBaseTestCase {
consoleApiParams = ConsoleApiParamsUtils.createFake(AuthResult.createUser(user));
response = (FakeResponse) consoleApiParams.response();
when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
doReturn(new BufferedReader(new StringReader(uiRegistrarMap.toString())))
.when(consoleApiParams.request())
.getReader();
return new RdapRegistrarFieldsAction(
consoleApiParams,
registrarAccessor,
ConsoleModule.provideRegistrar(
GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON)));
GSON, RequestModule.provideJsonBody(uiRegistrarMap.toString(), GSON)));
}
}
@@ -22,7 +22,6 @@ import static google.registry.testing.SqlHelper.saveRegistrar;
import static google.registry.util.DateTimeUtils.START_INSTANT;
import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableSet;
@@ -37,9 +36,7 @@ import google.registry.request.auth.AuthenticatedRegistrarAccessor;
import google.registry.testing.FakeResponse;
import google.registry.ui.server.console.ConsoleActionBaseTestCase;
import google.registry.ui.server.console.ConsoleModule;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
@@ -170,12 +167,8 @@ class SecurityActionTest extends ConsoleActionBaseTestCase {
String registrarId, String jsonBody, CertificateChecker certificateChecker)
throws IOException {
when(consoleApiParams.request().getMethod()).thenReturn(Action.Method.POST.toString());
doReturn(new BufferedReader(new StringReader(jsonBody)))
.when(consoleApiParams.request())
.getReader();
Optional<Registrar> maybeRegistrar =
ConsoleModule.provideRegistrar(
GSON, RequestModule.provideJsonBody(consoleApiParams.request(), GSON));
ConsoleModule.provideRegistrar(GSON, RequestModule.provideJsonBody(jsonBody, GSON));
return new SecurityAction(
consoleApiParams, certificateChecker, registrarAccessor, registrarId, maybeRegistrar);
}
@@ -0,0 +1,54 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<domain:chkData xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:cd>
<domain:name avail="true">example1.tld</domain:name>
</domain:cd>
<domain:cd>
<domain:name avail="true">example2.tld</domain:name>
</domain:cd>
<domain:cd>
<domain:name avail="true">example3.tld</domain:name>
</domain:cd>
</domain:chkData>
</resData>
<extension>
<fee:chkData xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:cd>
<fee:objID>example1.tld</fee:objID>
<fee:class>standard</fee:class>
<fee:command name="create">
<fee:period unit="y">1</fee:period>
<fee:fee description="create">13.00</fee:fee>
<fee:fee description="Expiry Access Period, fee expires: 2009-01-06T01:00:00Z">110.00</fee:fee>
</fee:command>
</fee:cd>
<fee:cd>
<fee:objID>example2.tld</fee:objID>
<fee:class>standard</fee:class>
<fee:command name="create">
<fee:period unit="y">1</fee:period>
<fee:fee description="create">13.00</fee:fee>
</fee:command>
</fee:cd>
<fee:cd>
<fee:objID>example3.tld</fee:objID>
<fee:class>standard</fee:class>
<fee:command name="create">
<fee:period unit="y">1</fee:period>
<fee:fee description="create">13.00</fee:fee>
</fee:command>
</fee:cd>
</fee:chkData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</epp>
@@ -0,0 +1,26 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<command>
<create>
<domain:create
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:name>rich.example</domain:name>
<domain:period unit="y">2</domain:period>
<domain:ns>
<domain:hostObj>ns1.example.net</domain:hostObj>
<domain:hostObj>ns2.example.net</domain:hostObj>
</domain:ns>
<domain:authInfo>
<domain:pw>2fooBAR</domain:pw>
</domain:authInfo>
</domain:create>
</create>
<extension>
<fee:create xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:fee description="create">200.00</fee:fee>
<fee:fee description="Expiry Access Period">100.00</fee:fee>
</fee:create>
</extension>
<clTRID>ABC-12345</clTRID>
</command>
</epp>
@@ -0,0 +1,26 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<domain:creData
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:name>rich.example</domain:name>
<domain:crDate>1999-04-03T22:00:00.0Z</domain:crDate>
<domain:exDate>2001-04-03T22:00:00.0Z</domain:exDate>
</domain:creData>
</resData>
<extension>
<fee:creData xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:fee description="create">200.00</fee:fee>
<fee:fee description="Expiry Access Period, fee expires: 1999-04-03T23:00:00Z">100.00</fee:fee>
</fee:creData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</epp>
@@ -0,0 +1,26 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<domain:creData
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:name>example.tld</domain:name>
<domain:crDate>1999-04-03T22:00:00.0Z</domain:crDate>
<domain:exDate>2001-04-03T22:00:00.0Z</domain:exDate>
</domain:creData>
</resData>
<extension>
<fee:creData xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:fee description="create">24.00</fee:fee>
<fee:fee description="Expiry Access Period, fee expires: 1999-04-03T23:00:00Z">100.00</fee:fee>
</fee:creData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</epp>
@@ -32,6 +32,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables:
- "EXTENDED_LATIN"
- "JA"
@@ -82,7 +82,7 @@ CONSOLE /console-api/password-reset-verify PasswordResetVerifyA
CONSOLE /console-api/registrar ConsoleUpdateRegistrarAction POST n USER PUBLIC
CONSOLE /console-api/registrars RegistrarsAction GET,POST n USER PUBLIC
CONSOLE /console-api/registry-lock ConsoleRegistryLockAction GET,POST n USER PUBLIC
CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockVerifyAction GET n USER PUBLIC
CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockVerifyAction POST n USER PUBLIC
CONSOLE /console-api/settings/contacts ContactAction GET,POST,DELETE,PUT n USER PUBLIC
CONSOLE /console-api/settings/rdap-fields RdapRegistrarFieldsAction POST n USER PUBLIC
CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC
@@ -21,15 +21,12 @@
-- "tld":"","tlds":["a.how"],"icannActivityReportField":"srs-dom-check"}
SELECT
-- Remove quotation marks from tld fields.
REGEXP_EXTRACT(tld, '^"(.*)"$') AS tld,
tld,
activityReportField AS metricName,
COUNT(*) AS count
FROM (
SELECT
-- TODO(b/32486667): Replace with JSON.parse() UDF when available for views
SPLIT(
REGEXP_EXTRACT(JSON_EXTRACT(json, '$.tlds'), r'^\[(.*)\]$')) AS tlds,
JSON_EXTRACT_STRING_ARRAY(json, '$.tlds') AS tlds,
JSON_EXTRACT_SCALAR(json,
'$.resourceType') AS resourceType,
JSON_EXTRACT_SCALAR(json,
@@ -43,10 +40,10 @@ FROM (
WHERE
STARTS_WITH(jsonPayload.message, "FLOW-LOG-SIGNATURE-METADATA")
AND _TABLE_SUFFIX BETWEEN '20170901' AND '20170930')
) AS regexes
) AS json_parsed
JOIN
-- Unnest the JSON-parsed tlds.
UNNEST(regexes.tlds) AS tld
UNNEST(json_parsed.tlds) AS tld
-- Exclude cases that can't be tabulated correctly, where activityReportField
-- is null/empty, or TLD is null/empty despite being a domain flow.
WHERE
@@ -19,6 +19,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables:
- "foo"
invoicingEnabled: false
@@ -19,6 +19,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -27,6 +27,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables:
- "EXTENDED_LATIN"
- "JA"
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "JPY"
amount: 0
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -22,6 +22,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -26,6 +26,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -18,6 +18,8 @@ reservedListNames: null
premiumListName: null
pricingEngineClassName: "google.registry.model.pricing.StaticPremiumListPricingEngine"
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
dnsPaused: false
addGracePeriodLength: "PT720H"
anchorTenantAddGracePeriodLength: "PT720H"
@@ -5,6 +5,8 @@ reservedListNames: []
dnsPaused: false
tldType: "REAL"
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
anchorTenantAddGracePeriodLength: "PT720H"
dnsNsTtl: null
tldStr: "outoforderfields"
@@ -26,6 +26,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -17,6 +17,8 @@ creationTime: "2022-09-01T00:00:00.000Z"
reservedListNames: []
premiumListName: "test"
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
dnsPaused: false
addGracePeriodLength: 432000000
anchorTenantAddGracePeriodLength: 2592000000
@@ -261,11 +261,11 @@ td.section {
</tr>
<tr>
<td class="property_name">generated on</td>
<td class="property_value">2026-07-06 17:57:29</td>
<td class="property_value">2026-07-14 19:18:20</td>
</tr>
<tr>
<td class="property_name">last flyway file</td>
<td id="lastFlywayFile" class="property_value">V224__add_registrar_expiry_access_period_enabled.sql</td>
<td id="lastFlywayFile" class="property_value">V225__user_registry_lock_email_address_index.sql</td>
</tr>
</tbody>
</table>
@@ -273,9 +273,9 @@ td.section {
<p>&nbsp;</p>
<svg viewBox="0.00 0.00 4783.00 3613.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="erDiagram" style="overflow: hidden; width: 100%; height: 800px">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 3608.5)">
<title>SchemaCrawler_Diagram</title> <polygon fill="white" stroke="none" points="-4,4 -4,-3608.5 4778.75,-3608.5 4778.75,4 -4,4" /> <text xml:space="preserve" text-anchor="start" x="4535.5" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">generated by</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">SchemaCrawler 17.11.1</text> <text xml:space="preserve" text-anchor="start" x="4534.75" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">generated on</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">2026-07-06 17:57:29</text> <polygon fill="none" stroke="#888888" points="4531.75,-4 4531.75,-45.5 4766.75,-45.5 4766.75,-4 4531.75,-4" /> <!-- allocationtoken_a08ccbef -->
<title>SchemaCrawler_Diagram</title> <polygon fill="white" stroke="none" points="-4,4 -4,-3608.5 4778.75,-3608.5 4778.75,4 -4,4" /> <text xml:space="preserve" text-anchor="start" x="4535.5" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">generated by</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-29.2" font-family="Helvetica,sans-Serif" font-size="14.00">SchemaCrawler 17.11.1</text> <text xml:space="preserve" text-anchor="start" x="4534.75" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">generated on</text> <text xml:space="preserve" text-anchor="start" x="4618.25" y="-9.45" font-family="Helvetica,sans-Serif" font-size="14.00">2026-07-14 19:18:20</text> <polygon fill="none" stroke="#888888" points="4531.75,-4 4531.75,-45.5 4766.75,-45.5 4766.75,-4 4531.75,-4" /> <!-- allocationtoken_a08ccbef -->
<g id="node1" class="node">
<title>allocationtoken_a08ccbef</title> <polygon fill="#e9c2f2" stroke="none" points="479.25,-1017.62 479.25,-1037.38 664.25,-1037.38 664.25,-1017.62 479.25,-1017.62" /> <text xml:space="preserve" text-anchor="start" x="481.25" y="-1023.08" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."AllocationToken"</text> <polygon fill="#e9c2f2" stroke="none" points="664.25,-1017.62 664.25,-1037.38 737.25,-1037.38 737.25,-1017.62 664.25,-1017.62" /> <text xml:space="preserve" text-anchor="start" x="698.5" y="-1022.08" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-1003.33" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">token</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-1002.33" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-1002.33" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-982.58" font-family="Helvetica,sans-Serif" font-size="14.00">domain_name</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-982.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-982.58" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-962.83" font-family="Helvetica,sans-Serif" font-size="14.00">redemption_domain_repo_id</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-962.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-962.83" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-943.08" font-family="Helvetica,sans-Serif" font-size="14.00">token_type</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-943.08" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-943.08" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <polygon fill="none" stroke="#888888" points="478.25,-937.62 478.25,-1038.38 738.25,-1038.38 738.25,-937.62 478.25,-937.62" />
<title>allocationtoken_a08ccbef</title> <polygon fill="#e9c2f2" stroke="none" points="479.25,-1014.62 479.25,-1034.38 664.25,-1034.38 664.25,-1014.62 479.25,-1014.62" /> <text xml:space="preserve" text-anchor="start" x="481.25" y="-1020.08" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."AllocationToken"</text> <polygon fill="#e9c2f2" stroke="none" points="664.25,-1014.62 664.25,-1034.38 737.25,-1034.38 737.25,-1014.62 664.25,-1014.62" /> <text xml:space="preserve" text-anchor="start" x="698.5" y="-1019.08" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-1000.33" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">token</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-999.33" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-999.33" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-979.58" font-family="Helvetica,sans-Serif" font-size="14.00">domain_name</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-979.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-979.58" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-959.83" font-family="Helvetica,sans-Serif" font-size="14.00">redemption_domain_repo_id</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-959.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-959.83" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="481.25" y="-940.08" font-family="Helvetica,sans-Serif" font-size="14.00">token_type</text> <text xml:space="preserve" text-anchor="start" x="658.5" y="-940.08" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="666.25" y="-940.08" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <polygon fill="none" stroke="#888888" points="478.25,-934.62 478.25,-1035.38 738.25,-1035.38 738.25,-934.62 478.25,-934.62" />
</g>
<!-- billingevent_a57d1815 -->
<g id="node2" class="node">
@@ -283,23 +283,23 @@ td.section {
</g>
<!-- billingevent_a57d1815&#45;&gt;allocationtoken_a08ccbef -->
<g id="edge1" class="edge">
<title>billingevent_a57d1815:w-&gt;allocationtoken_a08ccbef:e</title> <path fill="none" stroke="black" d="M1159.65,-977.35C982.41,-979.24 929.83,-1006.76 748.13,-1007.72" /> <polygon fill="black" stroke="black" points="1168.67,-977.3 1178.69,-981.75 1174,-977.28 1178.33,-977.25 1178.33,-977.25 1178.33,-977.25 1174,-977.28 1178.64,-972.75 1168.67,-977.3" /> <ellipse fill="none" stroke="black" cx="1162.95" cy="-977.33" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="739.26,-1012.75 739.24,-1002.75 741.24,-1002.74 741.26,-1012.74 739.26,-1012.75" /> <polyline fill="none" stroke="black" points="738.25,-1007.75 743.25,-1007.74" /> <polygon fill="black" stroke="black" points="744.26,-1012.73 744.24,-1002.73 746.24,-1002.73 746.26,-1012.73 744.26,-1012.73" /> <polyline fill="none" stroke="black" points="743.25,-1007.74 748.25,-1007.72" /> <text xml:space="preserve" text-anchor="start" x="870.62" y="-1009.35" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_event_allocation_token</text>
<title>billingevent_a57d1815:w-&gt;allocationtoken_a08ccbef:e</title> <path fill="none" stroke="black" d="M1159.66,-977.34C982.48,-979.04 929.75,-1003.85 748.12,-1004.73" /> <polygon fill="black" stroke="black" points="1168.67,-977.3 1178.69,-981.75 1174,-977.27 1178.33,-977.25 1178.33,-977.25 1178.33,-977.25 1174,-977.27 1178.64,-972.75 1168.67,-977.3" /> <ellipse fill="none" stroke="black" cx="1162.95" cy="-977.33" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="739.26,-1009.75 739.24,-999.75 741.24,-999.74 741.26,-1009.74 739.26,-1009.75" /> <polyline fill="none" stroke="black" points="738.25,-1004.75 743.25,-1004.74" /> <polygon fill="black" stroke="black" points="744.26,-1009.74 744.24,-999.74 746.24,-999.73 746.26,-1009.73 744.26,-1009.74" /> <polyline fill="none" stroke="black" points="743.25,-1004.74 748.25,-1004.73" /> <text xml:space="preserve" text-anchor="start" x="870.62" y="-1006.43" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_event_allocation_token</text>
</g>
<!-- billingrecurrence_5fa2cb01 -->
<g id="node7" class="node">
<title>billingrecurrence_5fa2cb01</title> <polygon fill="#e9c2f2" stroke="none" points="457.25,-891.12 457.25,-910.88 633.75,-910.88 633.75,-891.12 457.25,-891.12" /> <text xml:space="preserve" text-anchor="start" x="459.25" y="-896.58" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."BillingRecurrence"</text> <polygon fill="#e9c2f2" stroke="none" points="633.75,-891.12 633.75,-910.88 759.25,-910.88 759.25,-891.12 633.75,-891.12" /> <text xml:space="preserve" text-anchor="start" x="720.5" y="-895.58" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-876.83" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">billing_recurrence_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-875.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-875.83" font-family="Helvetica,sans-Serif" font-size="14.00">int8 not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-856.08" font-family="Helvetica,sans-Serif" font-size="14.00">registrar_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-856.08" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-856.08" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-836.33" font-family="Helvetica,sans-Serif" font-size="14.00">domain_history_revision_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-836.33" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-836.33" font-family="Helvetica,sans-Serif" font-size="14.00">int8 not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-816.58" font-family="Helvetica,sans-Serif" font-size="14.00">domain_repo_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-816.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-816.58" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-796.83" font-family="Helvetica,sans-Serif" font-size="14.00">event_time</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-796.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-796.83" font-family="Helvetica,sans-Serif" font-size="14.00">timestamptz not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-777.08" font-family="Helvetica,sans-Serif" font-size="14.00">recurrence_end_time</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-777.08" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-777.08" font-family="Helvetica,sans-Serif" font-size="14.00">timestamptz</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-757.33" font-family="Helvetica,sans-Serif" font-size="14.00">recurrence_time_of_year</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-757.33" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-757.33" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-737.58" font-family="Helvetica,sans-Serif" font-size="14.00">recurrence_last_expansion</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-737.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-737.58" font-family="Helvetica,sans-Serif" font-size="14.00">timestamptz not null</text> <polygon fill="none" stroke="#888888" points="456.25,-732.12 456.25,-911.88 760.25,-911.88 760.25,-732.12 456.25,-732.12" />
<title>billingrecurrence_5fa2cb01</title> <polygon fill="#e9c2f2" stroke="none" points="457.25,-888.12 457.25,-907.88 633.75,-907.88 633.75,-888.12 457.25,-888.12" /> <text xml:space="preserve" text-anchor="start" x="459.25" y="-893.58" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."BillingRecurrence"</text> <polygon fill="#e9c2f2" stroke="none" points="633.75,-888.12 633.75,-907.88 759.25,-907.88 759.25,-888.12 633.75,-888.12" /> <text xml:space="preserve" text-anchor="start" x="720.5" y="-892.58" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-873.83" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">billing_recurrence_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-872.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-872.83" font-family="Helvetica,sans-Serif" font-size="14.00">int8 not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-853.08" font-family="Helvetica,sans-Serif" font-size="14.00">registrar_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-853.08" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-853.08" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-833.33" font-family="Helvetica,sans-Serif" font-size="14.00">domain_history_revision_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-833.33" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-833.33" font-family="Helvetica,sans-Serif" font-size="14.00">int8 not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-813.58" font-family="Helvetica,sans-Serif" font-size="14.00">domain_repo_id</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-813.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-813.58" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-793.83" font-family="Helvetica,sans-Serif" font-size="14.00">event_time</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-793.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-793.83" font-family="Helvetica,sans-Serif" font-size="14.00">timestamptz not null</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-774.08" font-family="Helvetica,sans-Serif" font-size="14.00">recurrence_end_time</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-774.08" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-774.08" font-family="Helvetica,sans-Serif" font-size="14.00">timestamptz</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-754.33" font-family="Helvetica,sans-Serif" font-size="14.00">recurrence_time_of_year</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-754.33" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-754.33" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <text xml:space="preserve" text-anchor="start" x="459.25" y="-734.58" font-family="Helvetica,sans-Serif" font-size="14.00">recurrence_last_expansion</text> <text xml:space="preserve" text-anchor="start" x="627.75" y="-734.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="635.75" y="-734.58" font-family="Helvetica,sans-Serif" font-size="14.00">timestamptz not null</text> <polygon fill="none" stroke="#888888" points="456.25,-729.12 456.25,-908.88 760.25,-908.88 760.25,-729.12 456.25,-729.12" />
</g>
<!-- billingevent_a57d1815&#45;&gt;billingrecurrence_5fa2cb01 -->
<g id="edge9" class="edge">
<title>billingevent_a57d1815:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M1159.72,-937.55C991.18,-933.92 943.22,-883.19 770.2,-881.3" /> <polygon fill="black" stroke="black" points="1168.67,-937.64 1178.62,-942.25 1174,-937.7 1178.33,-937.74 1178.33,-937.74 1178.33,-937.74 1174,-937.7 1178.71,-933.25 1168.67,-937.64" /> <ellipse fill="none" stroke="black" cx="1162.95" cy="-937.58" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="761.22,-886.26 761.28,-876.26 763.28,-876.27 763.22,-886.27 761.22,-886.26" /> <polyline fill="none" stroke="black" points="760.25,-881.25 765.25,-881.28" /> <polygon fill="black" stroke="black" points="766.22,-886.28 766.28,-876.28 768.28,-876.29 768.22,-886.29 766.22,-886.28" /> <polyline fill="none" stroke="black" points="765.25,-881.28 770.25,-881.3" /> <text xml:space="preserve" text-anchor="start" x="786.25" y="-939.61" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_event_cancellation_matching_billing_recurrence_id</text>
<title>billingevent_a57d1815:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M1159.7,-937.53C991.02,-933.71 943.38,-880.29 770.21,-878.31" /> <polygon fill="black" stroke="black" points="1168.67,-937.63 1178.62,-942.25 1174,-937.69 1178.33,-937.74 1178.33,-937.74 1178.33,-937.74 1174,-937.69 1178.72,-933.25 1168.67,-937.63" /> <ellipse fill="none" stroke="black" cx="1162.95" cy="-937.57" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="761.22,-883.26 761.28,-873.26 763.28,-873.27 763.22,-883.27 761.22,-883.26" /> <polyline fill="none" stroke="black" points="760.25,-878.25 765.25,-878.28" /> <polygon fill="black" stroke="black" points="766.22,-883.28 766.28,-873.28 768.28,-873.3 768.22,-883.3 766.22,-883.28" /> <polyline fill="none" stroke="black" points="765.25,-878.28 770.25,-878.31" /> <text xml:space="preserve" text-anchor="start" x="786.25" y="-939.58" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_event_cancellation_matching_billing_recurrence_id</text>
</g>
<!-- registrar_6e1503e3 -->
<g id="node34" class="node">
<title>registrar_6e1503e3</title> <polygon fill="#e9c2f2" stroke="none" points="9,-672.75 9,-692.5 129.25,-692.5 129.25,-672.75 9,-672.75" /> <text xml:space="preserve" text-anchor="start" x="11" y="-678.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."Registrar"</text> <polygon fill="#e9c2f2" stroke="none" points="129.25,-672.75 129.25,-692.5 202.25,-692.5 202.25,-672.75 129.25,-672.75" /> <text xml:space="preserve" text-anchor="start" x="163.5" y="-677.2" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="11" y="-658.45" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">registrar_id</text> <text xml:space="preserve" text-anchor="start" x="115" y="-657.45" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="131.25" y="-657.45" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="11" y="-637.7" font-family="Helvetica,sans-Serif" font-size="14.00">iana_identifier</text> <text xml:space="preserve" text-anchor="start" x="115" y="-637.7" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="131.25" y="-637.7" font-family="Helvetica,sans-Serif" font-size="14.00">int8</text> <text xml:space="preserve" text-anchor="start" x="11" y="-617.95" font-family="Helvetica,sans-Serif" font-size="14.00">registrar_name</text> <text xml:space="preserve" text-anchor="start" x="115" y="-617.95" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="131.25" y="-617.95" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <polygon fill="none" stroke="#888888" points="8,-612.5 8,-693.5 203.25,-693.5 203.25,-612.5 8,-612.5" />
<title>registrar_6e1503e3</title> <polygon fill="#e9c2f2" stroke="none" points="9,-669.75 9,-689.5 129.25,-689.5 129.25,-669.75 9,-669.75" /> <text xml:space="preserve" text-anchor="start" x="11" y="-675.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."Registrar"</text> <polygon fill="#e9c2f2" stroke="none" points="129.25,-669.75 129.25,-689.5 202.25,-689.5 202.25,-669.75 129.25,-669.75" /> <text xml:space="preserve" text-anchor="start" x="163.5" y="-674.2" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="11" y="-655.45" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">registrar_id</text> <text xml:space="preserve" text-anchor="start" x="115" y="-654.45" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="131.25" y="-654.45" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="11" y="-634.7" font-family="Helvetica,sans-Serif" font-size="14.00">iana_identifier</text> <text xml:space="preserve" text-anchor="start" x="115" y="-634.7" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="131.25" y="-634.7" font-family="Helvetica,sans-Serif" font-size="14.00">int8</text> <text xml:space="preserve" text-anchor="start" x="11" y="-614.95" font-family="Helvetica,sans-Serif" font-size="14.00">registrar_name</text> <text xml:space="preserve" text-anchor="start" x="115" y="-614.95" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="131.25" y="-614.95" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <polygon fill="none" stroke="#888888" points="8,-609.5 8,-690.5 203.25,-690.5 203.25,-609.5 8,-609.5" />
</g>
<!-- billingevent_a57d1815&#45;&gt;registrar_6e1503e3 -->
<g id="edge30" class="edge">
<title>billingevent_a57d1815:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M1163.94,-1056.32C851.06,-1059.37 750.18,-1156.13 448.25,-1051 331.63,-1010.39 289.91,-987.56 229.25,-880 207.15,-840.81 245.33,-688.09 212.57,-665.64" /> <polygon fill="black" stroke="black" points="1168.67,-1056.3 1178.69,-1060.75 1174,-1056.27 1178.33,-1056.25 1178.33,-1056.25 1178.33,-1056.25 1174,-1056.27 1178.64,-1051.75 1168.67,-1056.3" /> <polygon fill="black" stroke="black" points="1166.42,-1051.31 1166.47,-1061.31 1164.47,-1061.32 1164.42,-1051.32 1166.42,-1051.31" /> <polyline fill="none" stroke="black" points="1167.45,-1056.31 1162.45,-1056.33" /> <polygon fill="black" stroke="black" points="202.79,-667.95 205.63,-658.37 207.55,-658.93 204.7,-668.52 202.79,-667.95" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.04,-664.3" /> <polygon fill="black" stroke="black" points="207.58,-669.37 210.42,-659.79 212.34,-660.36 209.5,-669.94 207.58,-669.37" /> <polyline fill="none" stroke="black" points="208.04,-664.3 212.84,-665.72" /> <text xml:space="preserve" text-anchor="start" x="524.25" y="-1102.32" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_event_registrar_id</text>
<title>billingevent_a57d1815:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M1163.94,-1056.32C851.04,-1059.3 749.98,-1153.75 448.25,-1048 331.71,-1007.15 289.91,-984.56 229.25,-877 207.15,-837.81 245.33,-685.09 212.57,-662.64" /> <polygon fill="black" stroke="black" points="1168.67,-1056.3 1178.69,-1060.75 1174,-1056.27 1178.33,-1056.25 1178.33,-1056.25 1178.33,-1056.25 1174,-1056.27 1178.64,-1051.75 1168.67,-1056.3" /> <polygon fill="black" stroke="black" points="1166.42,-1051.31 1166.47,-1061.31 1164.47,-1061.32 1164.42,-1051.32 1166.42,-1051.31" /> <polyline fill="none" stroke="black" points="1167.45,-1056.3 1162.45,-1056.33" /> <polygon fill="black" stroke="black" points="202.79,-664.95 205.63,-655.37 207.55,-655.93 204.7,-665.52 202.79,-664.95" /> <polyline fill="none" stroke="black" points="203.25,-659.88 208.04,-661.3" /> <polygon fill="black" stroke="black" points="207.58,-666.37 210.42,-656.79 212.34,-657.36 209.5,-666.94 207.58,-666.37" /> <polyline fill="none" stroke="black" points="208.04,-661.3 212.84,-662.72" /> <text xml:space="preserve" text-anchor="start" x="524.25" y="-1100.43" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_event_registrar_id</text>
</g>
<!-- domain_6c51cffa -->
<g id="node3" class="node">
@@ -307,7 +307,7 @@ td.section {
</g>
<!-- domain_6c51cffa&#45;&gt;allocationtoken_a08ccbef -->
<g id="edge2" class="edge">
<title>domain_6c51cffa:w-&gt;allocationtoken_a08ccbef:e</title> <path fill="none" stroke="black" d="M2585.78,-694.39C2580.42,-683.62 2580.11,-667.06 2565,-660 2553.58,-654.66 1918.04,-651.19 1610.5,-660.25 1414.92,-666.01 1360.06,-635.57 1171,-686 986.35,-735.26 913.86,-735.73 786.25,-878 747.68,-921 793.48,-997.72 748.19,-1006.85" /> <polygon fill="black" stroke="black" points="2593.31,-698.99 2599.49,-708.04 2597.86,-701.77 2601.55,-704.03 2601.55,-704.03 2601.55,-704.03 2597.86,-701.77 2604.19,-700.36 2593.31,-698.99" /> <ellipse fill="none" stroke="black" cx="2588.43" cy="-696.01" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="739.7,-1012.64 738.8,-1002.68 740.79,-1002.5 741.69,-1012.46 739.7,-1012.64" /> <polyline fill="none" stroke="black" points="738.25,-1007.75 743.23,-1007.3" /> <polygon fill="black" stroke="black" points="744.67,-1012.19 743.78,-1002.23 745.77,-1002.05 746.67,-1012.01 744.67,-1012.19" /> <polyline fill="none" stroke="black" points="743.23,-1007.3 748.21,-1006.85" /> <text xml:space="preserve" text-anchor="start" x="1628.88" y="-662.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_current_package_token</text>
<title>domain_6c51cffa:w-&gt;allocationtoken_a08ccbef:e</title> <path fill="none" stroke="black" d="M2585.78,-694.39C2580.42,-683.62 2580.11,-667.06 2565,-660 2553.58,-654.66 1918.04,-651.19 1610.5,-660.25 1414.92,-666.01 1360.21,-636.16 1171,-686 986.77,-734.53 913.9,-733.57 786.25,-875 747.55,-917.88 793.47,-994.71 748.19,-1003.85" /> <polygon fill="black" stroke="black" points="2593.31,-698.99 2599.49,-708.04 2597.86,-701.77 2601.55,-704.03 2601.55,-704.03 2601.55,-704.03 2597.86,-701.77 2604.19,-700.36 2593.31,-698.99" /> <ellipse fill="none" stroke="black" cx="2588.43" cy="-696.01" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="739.7,-1009.64 738.8,-999.68 740.79,-999.5 741.69,-1009.46 739.7,-1009.64" /> <polyline fill="none" stroke="black" points="738.25,-1004.75 743.23,-1004.3" /> <polygon fill="black" stroke="black" points="744.68,-1009.19 743.78,-999.23 745.77,-999.05 746.67,-1009.01 744.68,-1009.19" /> <polyline fill="none" stroke="black" points="743.23,-1004.3 748.21,-1003.85" /> <text xml:space="preserve" text-anchor="start" x="1628.88" y="-662.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_current_package_token</text>
</g>
<!-- domain_6c51cffa&#45;&gt;billingevent_a57d1815 -->
<g id="edge6" class="edge">
@@ -323,31 +323,31 @@ td.section {
</g>
<!-- domain_6c51cffa&#45;&gt;billingrecurrence_5fa2cb01 -->
<g id="edge10" class="edge">
<title>domain_6c51cffa:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M2585.7,-733.86C2580.34,-723.03 2580.1,-706.34 2565,-699 2423.8,-630.38 2015.73,-683.22 1858.75,-681.25 1748.43,-679.86 1720.83,-680.65 1610.5,-681.25 1415.16,-682.31 1364.5,-659.24 1171,-686 995.53,-710.26 912.35,-665.6 786.25,-790 759.04,-816.84 794.43,-869.97 769.92,-879.7" /> <polygon fill="black" stroke="black" points="2593.3,-738.49 2599.5,-747.54 2597.86,-741.27 2601.55,-743.53 2601.55,-743.53 2601.55,-743.53 2597.86,-741.27 2604.18,-739.86 2593.3,-738.49" /> <ellipse fill="none" stroke="black" cx="2588.42" cy="-735.51" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="762.03,-886.03 760.45,-876.15 762.42,-875.84 764,-885.71 762.03,-886.03" /> <polyline fill="none" stroke="black" points="760.25,-881.25 765.19,-880.46" /> <polygon fill="black" stroke="black" points="766.97,-885.24 765.38,-875.36 767.36,-875.05 768.94,-884.92 766.97,-885.24" /> <polyline fill="none" stroke="black" points="765.19,-880.46 770.12,-879.67" /> <text xml:space="preserve" text-anchor="start" x="1637.12" y="-683.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_billing_recurrence_id</text>
<title>domain_6c51cffa:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M2585.7,-733.86C2580.34,-723.03 2580.1,-706.34 2565,-699 2423.8,-630.38 2015.73,-683.22 1858.75,-681.25 1748.43,-679.86 1720.83,-680.65 1610.5,-681.25 1415.16,-682.31 1364.5,-659.24 1171,-686 995.53,-710.26 912.91,-666.16 786.25,-790 759.87,-815.79 793.22,-866.89 769.94,-876.63" /> <polygon fill="black" stroke="black" points="2593.3,-738.49 2599.5,-747.54 2597.86,-741.27 2601.55,-743.53 2601.55,-743.53 2601.55,-743.53 2597.86,-741.27 2604.18,-739.86 2593.3,-738.49" /> <ellipse fill="none" stroke="black" cx="2588.42" cy="-735.51" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="762.06,-883.02 760.41,-873.15 762.38,-872.82 764.03,-882.69 762.06,-883.02" /> <polyline fill="none" stroke="black" points="760.25,-878.25 765.18,-877.42" /> <polygon fill="black" stroke="black" points="766.99,-882.19 765.34,-872.33 767.31,-872 768.97,-881.86 766.99,-882.19" /> <polyline fill="none" stroke="black" points="765.18,-877.42 770.11,-876.6" /> <text xml:space="preserve" text-anchor="start" x="1637.12" y="-683.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_billing_recurrence_id</text>
</g>
<!-- domain_6c51cffa&#45;&gt;billingrecurrence_5fa2cb01 -->
<g id="edge11" class="edge">
<title>domain_6c51cffa:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M2582.85,-803.01C2322.71,-800.26 959.27,-751.28 786.25,-844 772.35,-851.45 776.62,-870 769.53,-877.75" /> <polygon fill="black" stroke="black" points="2591.79,-803.06 2601.76,-807.62 2597.13,-803.09 2601.46,-803.12 2601.46,-803.12 2601.46,-803.12 2597.13,-803.09 2601.82,-798.62 2591.79,-803.06" /> <ellipse fill="none" stroke="black" cx="2586.07" cy="-803.03" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="762.95,-885.58 759.42,-876.22 761.29,-875.51 764.82,-884.87 762.95,-885.58" /> <polyline fill="none" stroke="black" points="760.25,-881.25 764.93,-879.49" /> <polygon fill="black" stroke="black" points="767.63,-883.81 764.1,-874.45 765.97,-873.75 769.5,-883.11 767.63,-883.81" /> <polyline fill="none" stroke="black" points="764.93,-879.49 769.61,-877.72" /> <text xml:space="preserve" text-anchor="start" x="1610.5" y="-791.59" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_transfer_billing_recurrence_id</text>
<title>domain_6c51cffa:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M2582.86,-803.01C2322.78,-800.33 960.49,-752.6 786.25,-843 772.75,-850 776.36,-867.58 769.3,-874.93" /> <polygon fill="black" stroke="black" points="2591.79,-803.06 2601.76,-807.62 2597.13,-803.1 2601.46,-803.12 2601.46,-803.12 2601.46,-803.12 2597.13,-803.1 2601.82,-798.62 2591.79,-803.06" /> <ellipse fill="none" stroke="black" cx="2586.07" cy="-803.03" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="762.91,-882.6 759.47,-873.21 761.35,-872.52 764.79,-881.91 762.91,-882.6" /> <polyline fill="none" stroke="black" points="760.25,-878.25 764.94,-876.53" /> <polygon fill="black" stroke="black" points="767.6,-880.88 764.16,-871.49 766.04,-870.8 769.48,-880.19 767.6,-880.88" /> <polyline fill="none" stroke="black" points="764.94,-876.53 769.64,-874.81" /> <text xml:space="preserve" text-anchor="start" x="1610.5" y="-791.95" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_transfer_billing_recurrence_id</text>
</g>
<!-- domain_6c51cffa&#45;&gt;registrar_6e1503e3 -->
<g id="edge32" class="edge">
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2587.34,-1063.7C2566.55,-1078.68 2596.55,-1133.72 2565,-1159 2444.44,-1255.59 2353.04,-1127.22 2220.75,-1207 2209.03,-1214.07 2214.81,-1225.51 2202.75,-1232 2009.66,-1335.95 387.21,-1403.1 229.25,-1251 184.52,-1207.93 260.22,-716.72 212.08,-666.94" /> <polygon fill="black" stroke="black" points="2592.12,-1062.47 2602.93,-1064.32 2597.28,-1061.13 2601.48,-1060.04 2601.48,-1060.04 2601.48,-1060.04 2597.28,-1061.13 2600.67,-1055.6 2592.12,-1062.47" /> <polygon fill="black" stroke="black" points="2588.72,-1058.18 2591.23,-1067.86 2589.29,-1068.37 2586.78,-1058.68 2588.72,-1058.18" /> <polyline fill="none" stroke="black" points="2590.94,-1062.77 2586.1,-1064.03" /> <polygon fill="black" stroke="black" points="202.07,-667.84 206.25,-658.75 208.07,-659.59 203.88,-668.67 202.07,-667.84" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.79,-664.97" /> <polygon fill="black" stroke="black" points="206.61,-669.93 210.79,-660.84 212.61,-661.68 208.43,-670.76 206.61,-669.93" /> <polyline fill="none" stroke="black" points="207.79,-664.97 212.33,-667.06" /> <text xml:space="preserve" text-anchor="start" x="1290.62" y="-1340.68" font-family="Helvetica,sans-Serif" font-size="14.00">fk2jc69qyg2tv9hhnmif6oa1cx1</text>
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2587.34,-1063.7C2566.55,-1078.68 2596.55,-1133.72 2565,-1159 2444.44,-1255.59 2353.04,-1127.22 2220.75,-1207 2209.03,-1214.07 2214.81,-1225.51 2202.75,-1232 2009.66,-1335.95 387.2,-1403.12 229.25,-1251 184.3,-1207.71 260.49,-713.99 212.12,-663.96" /> <polygon fill="black" stroke="black" points="2592.12,-1062.47 2602.93,-1064.32 2597.28,-1061.13 2601.48,-1060.04 2601.48,-1060.04 2601.48,-1060.04 2597.28,-1061.13 2600.67,-1055.6 2592.12,-1062.47" /> <polygon fill="black" stroke="black" points="2588.72,-1058.18 2591.23,-1067.86 2589.29,-1068.37 2586.78,-1058.68 2588.72,-1058.18" /> <polyline fill="none" stroke="black" points="2590.94,-1062.77 2586.1,-1064.03" /> <polygon fill="black" stroke="black" points="202.07,-664.84 206.25,-655.75 208.07,-656.59 203.88,-665.67 202.07,-664.84" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.79,-661.97" /> <polygon fill="black" stroke="black" points="206.61,-666.93 210.79,-657.84 212.61,-658.68 208.43,-667.76 206.61,-666.93" /> <polyline fill="none" stroke="black" points="207.79,-661.97 212.33,-664.06" /> <text xml:space="preserve" text-anchor="start" x="1290.62" y="-1340.68" font-family="Helvetica,sans-Serif" font-size="14.00">fk2jc69qyg2tv9hhnmif6oa1cx1</text>
</g>
<!-- domain_6c51cffa&#45;&gt;registrar_6e1503e3 -->
<g id="edge33" class="edge">
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2587.23,-1024.33C2566.46,-1039.81 2597.86,-1096.54 2565,-1121 2442.07,-1212.5 2011.61,-1091.23 1876.75,-1164 1865.02,-1170.33 1870.48,-1181.66 1858.75,-1188 1754.44,-1244.38 1711.01,-1201.22 1592.5,-1205 1405.26,-1210.97 1358.33,-1205.6 1171,-1205 1066.36,-1204.67 304.78,-1271.42 229.25,-1199 188.64,-1160.06 254.59,-717.48 212.12,-667.47" /> <polygon fill="black" stroke="black" points="2592.14,-1023.03 2602.96,-1024.81 2597.29,-1021.66 2601.48,-1020.55 2601.48,-1020.55 2601.48,-1020.55 2597.29,-1021.66 2600.65,-1016.11 2592.14,-1023.03" /> <polygon fill="black" stroke="black" points="2588.71,-1018.77 2591.28,-1028.43 2589.34,-1028.94 2586.78,-1019.28 2588.71,-1018.77" /> <polyline fill="none" stroke="black" points="2590.96,-1023.34 2586.13,-1024.63" /> <polygon fill="black" stroke="black" points="201.84,-667.78 206.44,-658.89 208.21,-659.81 203.62,-668.69 201.84,-667.78" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.69,-665.17" /> <polygon fill="black" stroke="black" points="206.28,-670.07 210.88,-661.19 212.65,-662.11 208.06,-670.99 206.28,-670.07" /> <polyline fill="none" stroke="black" points="207.69,-665.17 212.13,-667.47" /> <text xml:space="preserve" text-anchor="start" x="1288.38" y="-1210.24" font-family="Helvetica,sans-Serif" font-size="14.00">fk2u3srsfbei272093m3b3xwj23</text>
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2587.23,-1024.33C2566.46,-1039.81 2597.86,-1096.54 2565,-1121 2442.07,-1212.5 2011.61,-1091.23 1876.75,-1164 1865.02,-1170.33 1870.48,-1181.66 1858.75,-1188 1754.44,-1244.38 1711.01,-1201.22 1592.5,-1205 1405.26,-1210.97 1358.33,-1205.6 1171,-1205 1066.36,-1204.67 304.77,-1271.43 229.25,-1199 188.33,-1159.76 255.13,-712.94 211.9,-664.18" /> <polygon fill="black" stroke="black" points="2592.14,-1023.03 2602.96,-1024.81 2597.29,-1021.66 2601.48,-1020.55 2601.48,-1020.55 2601.48,-1020.55 2597.29,-1021.66 2600.65,-1016.11 2592.14,-1023.03" /> <polygon fill="black" stroke="black" points="2588.71,-1018.77 2591.28,-1028.43 2589.34,-1028.94 2586.78,-1019.28 2588.71,-1018.77" /> <polyline fill="none" stroke="black" points="2590.96,-1023.34 2586.13,-1024.63" /> <polygon fill="black" stroke="black" points="201.92,-664.8 206.37,-655.85 208.16,-656.74 203.71,-665.69 201.92,-664.8" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.73,-662.1" /> <polygon fill="black" stroke="black" points="206.39,-667.03 210.85,-658.07 212.64,-658.97 208.18,-667.92 206.39,-667.03" /> <polyline fill="none" stroke="black" points="207.73,-662.1 212.2,-664.33" /> <text xml:space="preserve" text-anchor="start" x="1288.38" y="-1210.24" font-family="Helvetica,sans-Serif" font-size="14.00">fk2u3srsfbei272093m3b3xwj23</text>
</g>
<!-- domain_6c51cffa&#45;&gt;registrar_6e1503e3 -->
<g id="edge34" class="edge">
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2583.97,-987.06C2566.04,-1006.5 2599.77,-1066.56 2565,-1093 2533.75,-1116.77 1192.26,-1156.29 1153,-1157 839.77,-1162.7 755.05,-1206.41 448.25,-1143 346.16,-1121.9 291.09,-1142.92 229.25,-1059 204.98,-1026.06 241.33,-713.3 211.49,-668.27" /> <polygon fill="black" stroke="black" points="2592.34,-984.21 2603.26,-985.24 2597.39,-982.49 2601.49,-981.09 2601.49,-981.09 2601.49,-981.09 2597.39,-982.49 2600.36,-976.72 2592.34,-984.21" /> <ellipse fill="none" stroke="black" cx="2586.93" cy="-986.05" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="201.35,-667.61 206.82,-659.24 208.5,-660.33 203.02,-668.7 201.35,-667.61" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.43,-665.61" /> <polygon fill="black" stroke="black" points="205.53,-670.34 211.01,-661.98 212.68,-663.07 207.21,-671.44 205.53,-670.34" /> <polyline fill="none" stroke="black" points="207.43,-665.61 211.62,-668.35" /> <text xml:space="preserve" text-anchor="start" x="1293.25" y="-1158.93" font-family="Helvetica,sans-Serif" font-size="14.00">fkjc0r9r5y1lfbt4gpbqw4wsuvq</text>
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2583.97,-987.07C2566.04,-1006.5 2599.78,-1066.58 2565,-1093 2543.47,-1109.36 1619.53,-1136.18 1592.5,-1137 1397.2,-1142.89 1348.37,-1144.78 1153,-1147 839.76,-1150.56 755.79,-1195.59 448.25,-1136 346.52,-1116.29 291.39,-1138.93 229.25,-1056 204.71,-1023.25 241.29,-710.33 211.48,-665.27" /> <polygon fill="black" stroke="black" points="2592.34,-984.21 2603.26,-985.24 2597.39,-982.49 2601.49,-981.09 2601.49,-981.09 2601.49,-981.09 2597.39,-982.49 2600.36,-976.72 2592.34,-984.21" /> <ellipse fill="none" stroke="black" cx="2586.93" cy="-986.06" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="201.35,-664.61 206.83,-656.24 208.5,-657.34 203.02,-665.7 201.35,-664.61" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.43,-662.61" /> <polygon fill="black" stroke="black" points="205.53,-667.35 211.01,-658.98 212.68,-660.08 207.2,-668.44 205.53,-667.35" /> <polyline fill="none" stroke="black" points="207.43,-662.61 211.61,-665.35" /> <text xml:space="preserve" text-anchor="start" x="1293.25" y="-1149.22" font-family="Helvetica,sans-Serif" font-size="14.00">fkjc0r9r5y1lfbt4gpbqw4wsuvq</text>
</g>
<!-- domain_6c51cffa&#45;&gt;registrar_6e1503e3 -->
<g id="edge35" class="edge">
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2582.72,-783.37C1791.14,-782.9 1586.29,-753.81 786.25,-711 538.56,-697.75 468.63,-740.99 229.25,-676 220.86,-673.72 217.85,-668.58 212.85,-665.45" /> <polygon fill="black" stroke="black" points="2591.79,-783.37 2601.79,-787.87 2597.13,-783.37 2601.46,-783.37 2601.46,-783.37 2601.46,-783.37 2597.13,-783.37 2601.79,-778.87 2591.79,-783.37" /> <ellipse fill="none" stroke="black" cx="2586.07" cy="-783.37" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="202.92,-667.96 205.51,-658.3 207.44,-658.82 204.85,-668.48 202.92,-667.96" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.08,-664.17" /> <polygon fill="black" stroke="black" points="207.75,-669.26 210.34,-659.6 212.27,-660.12 209.68,-669.78 207.75,-669.26" /> <polyline fill="none" stroke="black" points="208.08,-664.17 212.91,-665.47" /> <text xml:space="preserve" text-anchor="start" x="1260.62" y="-760.12" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_transfer_gaining_registrar_id</text>
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2582.72,-783.37C1791.14,-782.94 1586.18,-755.95 786.25,-711 538.51,-697.08 468.54,-738.66 229.25,-673 220.86,-670.7 217.85,-665.56 212.85,-662.44" /> <polygon fill="black" stroke="black" points="2591.79,-783.37 2601.79,-787.87 2597.13,-783.37 2601.46,-783.37 2601.46,-783.37 2601.46,-783.37 2597.13,-783.37 2601.79,-778.87 2591.79,-783.37" /> <ellipse fill="none" stroke="black" cx="2586.07" cy="-783.37" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="202.92,-664.96 205.51,-655.3 207.44,-655.82 204.86,-665.48 202.92,-664.96" /> <polyline fill="none" stroke="black" points="203.25,-659.88 208.08,-661.17" /> <polygon fill="black" stroke="black" points="207.75,-666.26 210.34,-656.6 212.27,-657.11 209.69,-666.77 207.75,-666.26" /> <polyline fill="none" stroke="black" points="208.08,-661.17 212.91,-662.46" /> <text xml:space="preserve" text-anchor="start" x="1260.62" y="-761.03" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_transfer_gaining_registrar_id</text>
</g>
<!-- domain_6c51cffa&#45;&gt;registrar_6e1503e3 -->
<g id="edge36" class="edge">
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2582.92,-763.61C2149.56,-762.73 2034.9,-732.31 1592.5,-713.25 978.54,-686.8 822.77,-663.13 213.17,-662.88" /> <polygon fill="black" stroke="black" points="2591.79,-763.61 2601.79,-768.12 2597.13,-763.62 2601.46,-763.62 2601.46,-763.62 2601.46,-763.62 2597.13,-763.62 2601.8,-759.12 2591.79,-763.61" /> <ellipse fill="none" stroke="black" cx="2586.07" cy="-763.61" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="204.25,-667.88 204.25,-657.88 206.25,-657.88 206.25,-667.88 204.25,-667.88" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.25,-662.88" /> <polygon fill="black" stroke="black" points="209.25,-667.88 209.25,-657.88 211.25,-657.88 211.25,-667.88 209.25,-667.88" /> <polyline fill="none" stroke="black" points="208.25,-662.88 213.25,-662.88" /> <text xml:space="preserve" text-anchor="start" x="1264.75" y="-715.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_transfer_losing_registrar_id</text>
<title>domain_6c51cffa:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M2582.92,-763.61C2149.58,-762.77 2034.86,-733.72 1592.5,-714.25 978.5,-687.22 822.84,-660.17 213.17,-659.88" /> <polygon fill="black" stroke="black" points="2591.79,-763.62 2601.79,-768.12 2597.13,-763.62 2601.46,-763.62 2601.46,-763.62 2601.46,-763.62 2597.13,-763.62 2601.79,-759.12 2591.79,-763.62" /> <ellipse fill="none" stroke="black" cx="2586.07" cy="-763.61" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="204.25,-664.88 204.25,-654.88 206.25,-654.88 206.25,-664.88 204.25,-664.88" /> <polyline fill="none" stroke="black" points="203.25,-659.88 208.25,-659.88" /> <polygon fill="black" stroke="black" points="209.25,-664.88 209.25,-654.88 211.25,-654.88 211.25,-664.88 209.25,-664.88" /> <polyline fill="none" stroke="black" points="208.25,-659.88 213.25,-659.88" /> <text xml:space="preserve" text-anchor="start" x="1264.75" y="-716.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_transfer_losing_registrar_id</text>
</g>
<!-- tld_f1fa57e2 -->
<g id="node45" class="node">
@@ -363,7 +363,7 @@ td.section {
</g>
<!-- domainhistory_a54cc226&#45;&gt;allocationtoken_a08ccbef -->
<g id="edge3" class="edge">
<title>domainhistory_a54cc226:w-&gt;allocationtoken_a08ccbef:e</title> <path fill="none" stroke="black" d="M3184.99,-1290.94C2720.81,-1294.19 1607.58,-1410.17 1171,-1321 988.87,-1283.8 900.96,-1303.27 786.25,-1157 745.52,-1105.06 801.41,-1017.6 748.19,-1008.52" /> <polygon fill="black" stroke="black" points="3193.92,-1290.91 3203.93,-1295.38 3199.25,-1290.89 3203.58,-1290.88 3203.58,-1290.88 3203.58,-1290.88 3199.25,-1290.89 3203.9,-1286.38 3193.92,-1290.91" /> <ellipse fill="none" stroke="black" cx="3188.2" cy="-1290.93" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="738.86,-1012.81 739.63,-1002.84 741.63,-1003 740.86,-1012.97 738.86,-1012.81" /> <polyline fill="none" stroke="black" points="738.25,-1007.75 743.24,-1008.14" /> <polygon fill="black" stroke="black" points="743.85,-1013.2 744.62,-1003.23 746.61,-1003.38 745.84,-1013.35 743.85,-1013.2" /> <polyline fill="none" stroke="black" points="743.24,-1008.14 748.22,-1008.52" /> <text xml:space="preserve" text-anchor="start" x="1910.38" y="-1353.93" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_history_current_package_token</text>
<title>domainhistory_a54cc226:w-&gt;allocationtoken_a08ccbef:e</title> <path fill="none" stroke="black" d="M3184.99,-1290.95C2720.78,-1294.32 1606.58,-1414.92 1171,-1321 987.54,-1281.44 901.25,-1295.31 786.25,-1147 747.6,-1097.15 798.76,-1014.43 748.1,-1005.53" /> <polygon fill="black" stroke="black" points="3193.92,-1290.91 3203.93,-1295.38 3199.25,-1290.89 3203.58,-1290.88 3203.58,-1290.88 3203.58,-1290.88 3199.25,-1290.89 3203.9,-1286.38 3193.92,-1290.91" /> <ellipse fill="none" stroke="black" cx="3188.2" cy="-1290.94" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="738.85,-1009.81 739.64,-999.84 741.64,-1000 740.84,-1009.97 738.85,-1009.81" /> <polyline fill="none" stroke="black" points="738.25,-1004.75 743.23,-1005.15" /> <polygon fill="black" stroke="black" points="743.84,-1010.21 744.63,-1000.24 746.62,-1000.4 745.83,-1010.37 743.84,-1010.21" /> <polyline fill="none" stroke="black" points="743.23,-1005.15 748.22,-1005.54" /> <text xml:space="preserve" text-anchor="start" x="1910.38" y="-1356.05" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_history_current_package_token</text>
</g>
<!-- domainhistory_a54cc226&#45;&gt;domain_6c51cffa -->
<g id="edge16" class="edge">
@@ -371,7 +371,7 @@ td.section {
</g>
<!-- domainhistory_a54cc226&#45;&gt;registrar_6e1503e3 -->
<g id="edge37" class="edge">
<title>domainhistory_a54cc226:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3185.01,-1389.62C3005.57,-1389.58 2953.65,-1389 2765,-1389 2765,-1389 2765,-1389 607.25,-1389 434.13,-1389 338.65,-1429.16 229.25,-1295 208.51,-1269.57 234.47,-740.66 209.41,-670.53" /> <polygon fill="black" stroke="black" points="3193.92,-1389.62 3203.92,-1394.12 3199.25,-1389.62 3203.58,-1389.62 3203.58,-1389.62 3203.58,-1389.62 3199.25,-1389.62 3203.92,-1385.12 3193.92,-1389.62" /> <ellipse fill="none" stroke="black" cx="3188.2" cy="-1389.62" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="199.98,-666.79 207.77,-660.52 209.03,-662.08 201.23,-668.35 199.98,-666.79" /> <polyline fill="none" stroke="black" points="203.25,-662.88 206.38,-666.77" /> <polygon fill="black" stroke="black" points="203.11,-670.68 210.91,-664.42 212.16,-665.98 204.37,-672.24 203.11,-670.68" /> <polyline fill="none" stroke="black" points="206.38,-666.77 209.52,-670.67" /> <text xml:space="preserve" text-anchor="start" x="1642" y="-1391.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_history_registrar_id</text>
<title>domainhistory_a54cc226:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3185.01,-1389.62C3005.57,-1389.58 2953.65,-1389 2765,-1389 2765,-1389 2765,-1389 607.25,-1389 434.13,-1389 338.64,-1429.18 229.25,-1295 208.42,-1269.45 234.6,-738.03 209.44,-667.57" /> <polygon fill="black" stroke="black" points="3193.92,-1389.62 3203.92,-1394.12 3199.25,-1389.62 3203.58,-1389.62 3203.58,-1389.62 3203.58,-1389.62 3199.25,-1389.62 3203.92,-1385.12 3193.92,-1389.62" /> <ellipse fill="none" stroke="black" cx="3188.2" cy="-1389.62" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="199.98,-663.79 207.77,-657.52 209.03,-659.08 201.23,-665.35 199.98,-663.79" /> <polyline fill="none" stroke="black" points="203.25,-659.88 206.38,-663.77" /> <polygon fill="black" stroke="black" points="203.11,-667.68 210.91,-661.42 212.16,-662.98 204.37,-669.24 203.11,-667.68" /> <polyline fill="none" stroke="black" points="206.38,-663.77 209.52,-667.67" /> <text xml:space="preserve" text-anchor="start" x="1642" y="-1391.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_domain_history_registrar_id</text>
</g>
<!-- billingcancellation_6eedf614&#45;&gt;billingevent_a57d1815 -->
<g id="edge5" class="edge">
@@ -379,11 +379,11 @@ td.section {
</g>
<!-- billingcancellation_6eedf614&#45;&gt;billingrecurrence_5fa2cb01 -->
<g id="edge8" class="edge">
<title>billingcancellation_6eedf614:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M1865.49,-902.74C1750.06,-899.53 1716.87,-867.72 1592.5,-858.25 1235.09,-831.05 1143.31,-846.56 786.25,-878 778.82,-878.65 775.02,-879.8 770.18,-880.55" /> <polygon fill="black" stroke="black" points="1874.42,-902.86 1884.35,-907.5 1879.75,-902.93 1884.08,-902.99 1884.08,-902.99 1884.08,-902.99 1879.75,-902.93 1884.48,-898.5 1874.42,-902.86" /> <ellipse fill="none" stroke="black" cx="1868.7" cy="-902.78" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="761.6,-886.17 760.89,-876.19 762.89,-876.05 763.6,-886.03 761.6,-886.17" /> <polyline fill="none" stroke="black" points="760.25,-881.25 765.24,-880.9" /> <polygon fill="black" stroke="black" points="766.59,-885.81 765.88,-875.84 767.88,-875.7 768.58,-885.67 766.59,-885.81" /> <polyline fill="none" stroke="black" points="765.24,-880.9 770.22,-880.54" /> <text xml:space="preserve" text-anchor="start" x="1250.12" y="-860.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_cancellation_billing_recurrence_id</text>
<title>billingcancellation_6eedf614:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M1865.49,-902.74C1750.06,-899.53 1716.87,-867.72 1592.5,-858.25 1235.12,-831.05 1143.4,-844.95 786.25,-875 778.82,-875.63 775.02,-876.78 770.18,-877.53" /> <polygon fill="black" stroke="black" points="1874.42,-902.86 1884.35,-907.5 1879.75,-902.93 1884.08,-902.99 1884.08,-902.99 1884.08,-902.99 1879.75,-902.93 1884.48,-898.5 1874.42,-902.86" /> <ellipse fill="none" stroke="black" cx="1868.7" cy="-902.78" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="761.61,-883.17 760.89,-873.19 762.88,-873.05 763.6,-883.02 761.61,-883.17" /> <polyline fill="none" stroke="black" points="760.25,-878.25 765.24,-877.89" /> <polygon fill="black" stroke="black" points="766.59,-882.81 765.88,-872.83 767.87,-872.69 768.59,-882.66 766.59,-882.81" /> <polyline fill="none" stroke="black" points="765.24,-877.89 770.22,-877.53" /> <text xml:space="preserve" text-anchor="start" x="1250.12" y="-860.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_cancellation_billing_recurrence_id</text>
</g>
<!-- billingcancellation_6eedf614&#45;&gt;registrar_6e1503e3 -->
<g id="edge29" class="edge">
<title>billingcancellation_6eedf614:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M1871.01,-1027.94C1863.1,-1042.26 1879.59,-1076.77 1858.75,-1093 1764.9,-1166.07 1711.29,-1113.99 1592.5,-1120 1465.52,-1126.42 570.05,-1156.46 448.25,-1120 336.65,-1086.6 288.74,-1076.15 229.25,-976 212.99,-948.62 233.81,-710.69 211.1,-669.07" /> <polygon fill="black" stroke="black" points="1875.39,-1025.88 1886.36,-1025.72 1880.22,-1023.62 1884.14,-1021.78 1884.14,-1021.78 1884.14,-1021.78 1880.22,-1023.62 1882.54,-1017.57 1875.39,-1025.88" /> <polygon fill="black" stroke="black" points="1871.26,-1022.3 1875.5,-1031.35 1873.69,-1032.2 1869.45,-1023.15 1871.26,-1022.3" /> <polyline fill="none" stroke="black" points="1874.29,-1026.4 1869.76,-1028.52" /> <polygon fill="black" stroke="black" points="200.94,-667.42 207.13,-659.57 208.7,-660.81 202.51,-668.66 200.94,-667.42" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.17,-665.97" /> <polygon fill="black" stroke="black" points="204.86,-670.52 211.06,-662.67 212.63,-663.91 206.43,-671.76 204.86,-670.52" /> <polyline fill="none" stroke="black" points="207.17,-665.97 211.1,-669.07" /> <text xml:space="preserve" text-anchor="start" x="866.5" y="-1140.18" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_cancellation_registrar_id</text>
<title>billingcancellation_6eedf614:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M1871.01,-1027.94C1863.1,-1042.26 1879.59,-1076.77 1858.75,-1093 1764.9,-1166.07 1711.29,-1113.99 1592.5,-1120 1405.62,-1129.45 491.29,-1131.02 448.25,-1118 336.52,-1084.19 288.71,-1073.46 229.25,-973 213,-945.54 233.9,-706.66 210.95,-665.81" /> <polygon fill="black" stroke="black" points="1875.39,-1025.88 1886.36,-1025.72 1880.22,-1023.62 1884.14,-1021.78 1884.14,-1021.78 1884.14,-1021.78 1880.22,-1023.62 1882.54,-1017.57 1875.39,-1025.88" /> <polygon fill="black" stroke="black" points="1871.26,-1022.3 1875.5,-1031.35 1873.69,-1032.2 1869.45,-1023.15 1871.26,-1022.3" /> <polyline fill="none" stroke="black" points="1874.29,-1026.4 1869.76,-1028.52" /> <polygon fill="black" stroke="black" points="200.99,-664.45 207.09,-656.52 208.68,-657.74 202.58,-665.67 200.99,-664.45" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.21,-662.93" /> <polygon fill="black" stroke="black" points="204.95,-667.5 211.05,-659.57 212.64,-660.79 206.54,-668.72 204.95,-667.5" /> <polyline fill="none" stroke="black" points="207.21,-662.93 211.17,-665.98" /> <text xml:space="preserve" text-anchor="start" x="866.5" y="-1129.88" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_cancellation_registrar_id</text>
</g>
<!-- graceperiod_cd3b2e8f -->
<g id="node6" class="node">
@@ -399,15 +399,15 @@ td.section {
</g>
<!-- graceperiod_cd3b2e8f&#45;&gt;billingrecurrence_5fa2cb01 -->
<g id="edge12" class="edge">
<title>graceperiod_cd3b2e8f:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M3222.93,-691.41C3113.42,-694.69 3071.21,-723.99 2963,-678 2952.87,-673.7 2955.15,-665.24 2945,-661 2763.12,-585.01 1367.79,-640.73 1171,-652 999.57,-661.82 910.21,-568.18 786.25,-687 757.51,-714.55 795.98,-854.03 769.54,-877.8" /> <polygon fill="black" stroke="black" points="3231.92,-691.28 3241.98,-695.63 3237.25,-691.2 3241.58,-691.13 3241.58,-691.13 3241.58,-691.13 3237.25,-691.2 3241.85,-686.63 3231.92,-691.28" /> <ellipse fill="none" stroke="black" cx="3226.2" cy="-691.36" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="762.93,-885.59 759.45,-876.21 761.32,-875.52 764.8,-884.89 762.93,-885.59" /> <polyline fill="none" stroke="black" points="760.25,-881.25 764.94,-879.51" /> <polygon fill="black" stroke="black" points="767.61,-883.85 764.14,-874.47 766.01,-873.78 769.49,-883.15 767.61,-883.85" /> <polyline fill="none" stroke="black" points="764.94,-879.51 769.63,-877.77" /> <text xml:space="preserve" text-anchor="start" x="1925.38" y="-629.59" font-family="Helvetica,sans-Serif" font-size="14.00">fk_grace_period_billing_recurrence_id</text>
<title>graceperiod_cd3b2e8f:w-&gt;billingrecurrence_5fa2cb01:e</title> <path fill="none" stroke="black" d="M3222.93,-691.41C3113.42,-694.69 3071.21,-723.99 2963,-678 2952.87,-673.7 2955.15,-665.24 2945,-661 2763.12,-585.01 1367.8,-641.84 1171,-653 999.61,-662.72 910.45,-568.5 786.25,-687 757.88,-714.06 795.49,-851.44 769.4,-874.86" /> <polygon fill="black" stroke="black" points="3231.92,-691.28 3241.98,-695.63 3237.25,-691.2 3241.58,-691.13 3241.58,-691.13 3241.58,-691.13 3237.25,-691.2 3241.85,-686.63 3231.92,-691.28" /> <ellipse fill="none" stroke="black" cx="3226.2" cy="-691.36" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="762.93,-882.59 759.45,-873.21 761.32,-872.52 764.8,-881.89 762.93,-882.59" /> <polyline fill="none" stroke="black" points="760.25,-878.25 764.94,-876.51" /> <polygon fill="black" stroke="black" points="767.61,-880.85 764.14,-871.48 766.01,-870.78 769.49,-880.16 767.61,-880.85" /> <polyline fill="none" stroke="black" points="764.94,-876.51 769.63,-874.77" /> <text xml:space="preserve" text-anchor="start" x="1925.38" y="-630.25" font-family="Helvetica,sans-Serif" font-size="14.00">fk_grace_period_billing_recurrence_id</text>
</g>
<!-- graceperiod_cd3b2e8f&#45;&gt;registrar_6e1503e3 -->
<g id="edge38" class="edge">
<title>graceperiod_cd3b2e8f:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3227.25,-671.58C3156.88,-673.26 3011.82,-683.75 2963,-653 2949.19,-644.3 2959.04,-629.32 2945,-621 2737.34,-497.91 2100.15,-609.67 1858.75,-608.25 1748.42,-607.6 1720.83,-607.33 1610.5,-608.25 1244.13,-611.31 1152.32,-606.8 786.25,-622 538.36,-632.29 475.93,-632.47 229.25,-659 221.81,-659.8 218.03,-661.17 213.2,-662.04" /> <polygon fill="black" stroke="black" points="3231.92,-671.52 3241.98,-675.88 3237.25,-671.44 3241.58,-671.38 3241.58,-671.38 3241.58,-671.38 3237.25,-671.44 3241.85,-666.88 3231.92,-671.52" /> <polygon fill="black" stroke="black" points="3229.63,-666.55 3229.77,-676.55 3227.77,-676.58 3227.63,-666.58 3229.63,-666.55" /> <polyline fill="none" stroke="black" points="3230.7,-671.53 3225.7,-671.6" /> <polygon fill="black" stroke="black" points="204.66,-667.77 203.83,-657.81 205.82,-657.64 206.66,-667.61 204.66,-667.77" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.23,-662.46" /> <polygon fill="black" stroke="black" points="209.65,-667.36 208.81,-657.39 210.81,-657.23 211.64,-667.19 209.65,-667.36" /> <polyline fill="none" stroke="black" points="208.23,-662.46 213.22,-662.04" /> <text xml:space="preserve" text-anchor="start" x="1648.75" y="-610.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_grace_period_registrar_id</text>
<title>graceperiod_cd3b2e8f:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3227.25,-671.58C3156.88,-673.26 3011.82,-683.75 2963,-653 2949.19,-644.3 2959.04,-629.32 2945,-621 2737.34,-497.91 2100.15,-609.67 1858.75,-608.25 1748.42,-607.6 1720.83,-607.26 1610.5,-608.25 1244.12,-611.53 1152.37,-608.88 786.25,-623 538.44,-632.55 475.91,-630.36 229.25,-656 221.81,-656.77 218.02,-658.15 213.2,-659.03" /> <polygon fill="black" stroke="black" points="3231.92,-671.52 3241.98,-675.88 3237.25,-671.44 3241.58,-671.38 3241.58,-671.38 3241.58,-671.38 3237.25,-671.44 3241.85,-666.88 3231.92,-671.52" /> <polygon fill="black" stroke="black" points="3229.63,-666.55 3229.77,-676.55 3227.77,-676.58 3227.63,-666.58 3229.63,-666.55" /> <polyline fill="none" stroke="black" points="3230.7,-671.53 3225.7,-671.6" /> <polygon fill="black" stroke="black" points="204.67,-664.77 203.82,-654.81 205.82,-654.64 206.66,-664.6 204.67,-664.77" /> <polyline fill="none" stroke="black" points="203.25,-659.88 208.23,-659.45" /> <polygon fill="black" stroke="black" points="209.65,-664.35 208.81,-654.39 210.8,-654.22 211.64,-664.18 209.65,-664.35" /> <polyline fill="none" stroke="black" points="208.23,-659.45 213.21,-659.03" /> <text xml:space="preserve" text-anchor="start" x="1648.75" y="-610.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_grace_period_registrar_id</text>
</g>
<!-- billingrecurrence_5fa2cb01&#45;&gt;registrar_6e1503e3 -->
<g id="edge31" class="edge">
<title>billingrecurrence_5fa2cb01:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M440.92,-861.17C333.52,-856.34 306.18,-799.52 229.25,-714 215,-698.16 224.22,-673.08 212.74,-665.27" /> <polygon fill="black" stroke="black" points="445.92,-861.27 455.82,-865.99 451.25,-861.39 455.58,-861.49 455.58,-861.49 455.58,-861.49 451.25,-861.39 456.01,-856.99 445.92,-861.27" /> <polygon fill="black" stroke="black" points="443.81,-856.23 443.59,-866.23 441.59,-866.18 441.81,-856.18 443.81,-856.23" /> <polyline fill="none" stroke="black" points="444.7,-861.25 439.7,-861.14" /> <polygon fill="black" stroke="black" points="202.99,-667.97 205.44,-658.27 207.38,-658.76 204.93,-668.46 202.99,-667.97" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.1,-664.1" /> <polygon fill="black" stroke="black" points="207.84,-669.19 210.29,-659.5 212.23,-659.99 209.78,-669.68 207.84,-669.19" /> <polyline fill="none" stroke="black" points="208.1,-664.1 212.95,-665.33" /> <text xml:space="preserve" text-anchor="start" x="230" y="-862.85" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_recurrence_registrar_id</text>
<title>billingrecurrence_5fa2cb01:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M440.92,-858.17C333.52,-853.34 306.18,-796.52 229.25,-711 215,-695.16 224.22,-670.08 212.74,-662.27" /> <polygon fill="black" stroke="black" points="445.92,-858.27 455.82,-862.99 451.25,-858.39 455.58,-858.49 455.58,-858.49 455.58,-858.49 451.25,-858.39 456.01,-853.99 445.92,-858.27" /> <polygon fill="black" stroke="black" points="443.81,-853.23 443.59,-863.23 441.59,-863.18 441.81,-853.18 443.81,-853.23" /> <polyline fill="none" stroke="black" points="444.7,-858.25 439.7,-858.14" /> <polygon fill="black" stroke="black" points="202.99,-664.97 205.44,-655.27 207.38,-655.76 204.93,-665.46 202.99,-664.97" /> <polyline fill="none" stroke="black" points="203.25,-659.88 208.1,-661.1" /> <polygon fill="black" stroke="black" points="207.84,-666.19 210.29,-656.5 212.23,-656.99 209.78,-666.68 207.84,-666.19" /> <polyline fill="none" stroke="black" points="208.1,-661.1 212.95,-662.33" /> <text xml:space="preserve" text-anchor="start" x="230" y="-859.85" font-family="Helvetica,sans-Serif" font-size="14.00">fk_billing_recurrence_registrar_id</text>
</g>
<!-- bsadomainrefresh_c8f4c45d -->
<g id="node8" class="node">
@@ -451,11 +451,11 @@ td.section {
</g>
<!-- user_f2216f01 -->
<g id="node47" class="node">
<title>user_f2216f01</title> <polygon fill="#e9c2f2" stroke="none" points="3840,-1807 3840,-1826.75 3946.25,-1826.75 3946.25,-1807 3840,-1807" /> <text xml:space="preserve" text-anchor="start" x="3842" y="-1812.45" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."User"</text> <polygon fill="#e9c2f2" stroke="none" points="3946.25,-1807 3946.25,-1826.75 4019.25,-1826.75 4019.25,-1807 3946.25,-1807" /> <text xml:space="preserve" text-anchor="start" x="3980.5" y="-1811.45" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="3842" y="-1792.7" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">email_address</text> <text xml:space="preserve" text-anchor="start" x="3940.5" y="-1791.7" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="3948.25" y="-1791.7" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <polygon fill="none" stroke="#888888" points="3839,-1786.25 3839,-1827.75 4020.25,-1827.75 4020.25,-1786.25 3839,-1786.25" />
<title>user_f2216f01</title> <polygon fill="#e9c2f2" stroke="none" points="3801.38,-1807.88 3801.38,-1827.62 3984.88,-1827.62 3984.88,-1807.88 3801.38,-1807.88" /> <text xml:space="preserve" text-anchor="start" x="3803.38" y="-1813.33" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">public."User"</text> <polygon fill="#e9c2f2" stroke="none" points="3984.88,-1807.88 3984.88,-1827.62 4057.88,-1827.62 4057.88,-1807.88 3984.88,-1807.88" /> <text xml:space="preserve" text-anchor="start" x="4019.12" y="-1812.33" font-family="Helvetica,sans-Serif" font-size="14.00">[table]</text> <text xml:space="preserve" text-anchor="start" x="3803.38" y="-1793.58" font-family="Helvetica,sans-Serif" font-weight="bold" font-style="italic" font-size="14.00">email_address</text> <text xml:space="preserve" text-anchor="start" x="3979.12" y="-1792.58" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="3986.88" y="-1792.58" font-family="Helvetica,sans-Serif" font-size="14.00">text not null</text> <text xml:space="preserve" text-anchor="start" x="3803.38" y="-1772.83" font-family="Helvetica,sans-Serif" font-size="14.00">registry_lock_email_address</text> <text xml:space="preserve" text-anchor="start" x="3979.12" y="-1772.83" font-family="Helvetica,sans-Serif" font-size="14.00"> </text> <text xml:space="preserve" text-anchor="start" x="3986.88" y="-1772.83" font-family="Helvetica,sans-Serif" font-size="14.00">text</text> <polygon fill="none" stroke="#888888" points="3800.38,-1767.38 3800.38,-1828.62 4058.88,-1828.62 4058.88,-1767.38 3800.38,-1767.38" />
</g>
<!-- consoleupdatehistory_5237b2aa&#45;&gt;user_f2216f01 -->
<g id="edge55" class="edge">
<title>consoleupdatehistory_5237b2aa:w-&gt;user_f2216f01:e</title> <path fill="none" stroke="black" d="M4397.75,-1797.5C4238.8,-1797.48 4191.43,-1797.14 4030.03,-1797.13" /> <polygon fill="black" stroke="black" points="4402.42,-1797.5 4412.42,-1802 4407.75,-1797.5 4412.08,-1797.5 4412.08,-1797.5 4412.08,-1797.5 4407.75,-1797.5 4412.42,-1793 4402.42,-1797.5" /> <polygon fill="black" stroke="black" points="4400.2,-1792.5 4400.2,-1802.5 4398.2,-1802.5 4398.2,-1792.5 4400.2,-1792.5" /> <polyline fill="none" stroke="black" points="4401.2,-1797.5 4396.2,-1797.5" /> <polygon fill="black" stroke="black" points="4021.25,-1802.13 4021.25,-1792.13 4023.25,-1792.13 4023.25,-1802.13 4021.25,-1802.13" /> <polyline fill="none" stroke="black" points="4020.25,-1797.12 4025.25,-1797.13" /> <polygon fill="black" stroke="black" points="4026.25,-1802.13 4026.25,-1792.13 4028.25,-1792.13 4028.25,-1802.13 4026.25,-1802.13" /> <polyline fill="none" stroke="black" points="4025.25,-1797.13 4030.25,-1797.13" /> <text xml:space="preserve" text-anchor="start" x="4129" y="-1799.94" font-family="Helvetica,sans-Serif" font-size="14.00">fk_console_update_history_acting_user</text>
<title>consoleupdatehistory_5237b2aa:w-&gt;user_f2216f01:e</title> <path fill="none" stroke="black" d="M4397.47,-1797.5C4255.84,-1797.53 4212.77,-1797.98 4068.52,-1798" /> <polygon fill="black" stroke="black" points="4402.42,-1797.5 4412.42,-1802 4407.75,-1797.5 4412.08,-1797.5 4412.08,-1797.5 4412.08,-1797.5 4407.75,-1797.5 4412.42,-1793 4402.42,-1797.5" /> <polygon fill="black" stroke="black" points="4400.2,-1792.5 4400.2,-1802.5 4398.2,-1802.5 4398.2,-1792.5 4400.2,-1792.5" /> <polyline fill="none" stroke="black" points="4401.2,-1797.5 4396.2,-1797.5" /> <polygon fill="black" stroke="black" points="4059.88,-1803 4059.87,-1793 4061.87,-1793 4061.88,-1803 4059.88,-1803" /> <polyline fill="none" stroke="black" points="4058.88,-1798 4063.87,-1798" /> <polygon fill="black" stroke="black" points="4064.88,-1803 4064.87,-1793 4066.87,-1793 4066.88,-1803 4064.88,-1803" /> <polyline fill="none" stroke="black" points="4063.87,-1798 4068.87,-1798" /> <text xml:space="preserve" text-anchor="start" x="4129" y="-1800.41" font-family="Helvetica,sans-Serif" font-size="14.00">fk_console_update_history_acting_user</text>
</g>
<!-- cursor_6af40e8c -->
<g id="node16" class="node">
@@ -495,15 +495,15 @@ td.section {
</g>
<!-- host_f21b78de&#45;&gt;registrar_6e1503e3 -->
<g id="edge39" class="edge">
<title>host_f21b78de:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3198.12,-512.36C3012.86,-512.16 2959.48,-509 2765,-509 2765,-509 2765,-509 607.25,-509 430.02,-509 379.12,-541.4 229.25,-636 218.71,-642.65 218.7,-654.44 212.35,-659.92" /> <polygon fill="black" stroke="black" points="3207.04,-512.37 3217.04,-516.87 3212.38,-512.37 3216.71,-512.37 3216.71,-512.37 3216.71,-512.37 3212.38,-512.37 3217.04,-507.87 3207.04,-512.37" /> <ellipse fill="none" stroke="black" cx="3201.32" cy="-512.37" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="205.74,-667.32 202.66,-657.81 204.56,-657.19 207.65,-666.71 205.74,-667.32" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.01,-661.33" /> <polygon fill="black" stroke="black" points="210.5,-665.78 207.41,-656.27 209.32,-655.65 212.4,-665.16 210.5,-665.78" /> <polyline fill="none" stroke="black" points="208.01,-661.33 212.76,-659.79" /> <text xml:space="preserve" text-anchor="start" x="1647.62" y="-511.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_host_creation_registrar_id</text>
<title>host_f21b78de:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3198.12,-512.36C3012.86,-512.16 2959.48,-509 2765,-509 2765,-509 2765,-509 607.25,-509 430.3,-509 379.99,-541.33 229.25,-634 219.05,-640.27 218.6,-651.29 212.63,-656.7" /> <polygon fill="black" stroke="black" points="3207.04,-512.37 3217.04,-516.87 3212.38,-512.37 3216.71,-512.37 3216.71,-512.37 3216.71,-512.37 3212.38,-512.37 3217.04,-507.87 3207.04,-512.37" /> <ellipse fill="none" stroke="black" cx="3201.32" cy="-512.37" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="205.8,-664.29 202.59,-654.82 204.49,-654.18 207.7,-663.65 205.8,-664.29" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.99,-658.27" /> <polygon fill="black" stroke="black" points="210.54,-662.68 207.33,-653.21 209.22,-652.57 212.43,-662.04 210.54,-662.68" /> <polyline fill="none" stroke="black" points="207.99,-658.27 212.72,-656.66" /> <text xml:space="preserve" text-anchor="start" x="1647.62" y="-511.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_host_creation_registrar_id</text>
</g>
<!-- host_f21b78de&#45;&gt;registrar_6e1503e3 -->
<g id="edge40" class="edge">
<title>host_f21b78de:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3198.12,-472.87C3012.87,-472.69 2959.48,-470 2765,-470 2765,-470 2765,-470 607.25,-470 437.42,-470 351.76,-408.37 229.25,-526 187.29,-566.29 255.87,-651.68 212.99,-661.87" /> <polygon fill="black" stroke="black" points="3207.04,-472.87 3217.04,-477.37 3212.38,-472.87 3216.71,-472.87 3216.71,-472.87 3216.71,-472.87 3212.38,-472.87 3217.04,-468.37 3207.04,-472.87" /> <ellipse fill="none" stroke="black" cx="3201.32" cy="-472.87" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="204.76,-667.75 203.73,-657.8 205.72,-657.59 206.75,-667.54 204.76,-667.75" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.22,-662.36" /> <polygon fill="black" stroke="black" points="209.73,-667.24 208.71,-657.29 210.7,-657.08 211.72,-667.03 209.73,-667.24" /> <polyline fill="none" stroke="black" points="208.22,-662.36 213.2,-661.85" /> <text xml:space="preserve" text-anchor="start" x="1622.88" y="-472.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_host_current_sponsor_registrar_id</text>
<title>host_f21b78de:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3198.12,-472.87C3012.87,-472.69 2959.48,-470 2765,-470 2765,-470 2765,-470 607.25,-470 437.42,-470 352,-408.63 229.25,-526 188.18,-565.27 254.54,-648.57 213.05,-658.83" /> <polygon fill="black" stroke="black" points="3207.04,-472.87 3217.04,-477.37 3212.38,-472.87 3216.71,-472.87 3216.71,-472.87 3216.71,-472.87 3212.38,-472.87 3217.04,-468.37 3207.04,-472.87" /> <ellipse fill="none" stroke="black" cx="3201.32" cy="-472.87" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="204.77,-664.74 203.72,-654.8 205.7,-654.59 206.76,-664.53 204.77,-664.74" /> <polyline fill="none" stroke="black" points="203.25,-659.88 208.22,-659.35" /> <polygon fill="black" stroke="black" points="209.75,-664.21 208.69,-654.27 210.68,-654.06 211.73,-664 209.75,-664.21" /> <polyline fill="none" stroke="black" points="208.22,-659.35 213.19,-658.82" /> <text xml:space="preserve" text-anchor="start" x="1622.88" y="-472.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_host_current_sponsor_registrar_id</text>
</g>
<!-- host_f21b78de&#45;&gt;registrar_6e1503e3 -->
<g id="edge41" class="edge">
<title>host_f21b78de:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3198.12,-433.37C3012.87,-433.22 2959.48,-431 2765,-431 2765,-431 2765,-431 607.25,-431 439.04,-431 352.36,-335.37 229.25,-450 197.1,-479.94 242.63,-635.59 212.61,-659.73" /> <polygon fill="black" stroke="black" points="3207.04,-433.37 3217.04,-437.87 3212.38,-433.37 3216.71,-433.37 3216.71,-433.37 3216.71,-433.37 3212.38,-433.37 3217.04,-428.87 3207.04,-433.37" /> <ellipse fill="none" stroke="black" cx="3201.32" cy="-433.37" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="205.79,-667.3 202.6,-657.82 204.5,-657.18 207.69,-666.66 205.79,-667.3" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.99,-661.28" /> <polygon fill="black" stroke="black" points="210.53,-665.7 207.34,-656.22 209.24,-655.59 212.43,-665.06 210.53,-665.7" /> <polyline fill="none" stroke="black" points="207.99,-661.28 212.73,-659.69" /> <text xml:space="preserve" text-anchor="start" x="1621.75" y="-433.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_host_last_epp_update_registrar_id</text>
<title>host_f21b78de:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3198.12,-433.37C3012.87,-433.22 2959.48,-431 2765,-431 2765,-431 2765,-431 607.25,-431 439.04,-431 352.46,-335.48 229.25,-450 197.52,-479.5 242.13,-632.97 212.48,-656.77" /> <polygon fill="black" stroke="black" points="3207.04,-433.37 3217.04,-437.87 3212.38,-433.37 3216.71,-433.37 3216.71,-433.37 3216.71,-433.37 3212.38,-433.37 3217.04,-428.87 3207.04,-433.37" /> <ellipse fill="none" stroke="black" cx="3201.32" cy="-433.37" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="205.79,-664.3 202.6,-654.82 204.5,-654.18 207.69,-663.66 205.79,-664.3" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.99,-658.28" /> <polygon fill="black" stroke="black" points="210.53,-662.7 207.34,-653.22 209.24,-652.59 212.43,-662.07 210.53,-662.7" /> <polyline fill="none" stroke="black" points="207.99,-658.28 212.73,-656.69" /> <text xml:space="preserve" text-anchor="start" x="1621.75" y="-433.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_host_last_epp_update_registrar_id</text>
</g>
<!-- pollmessage_614a523e -->
<g id="node21" class="node">
@@ -531,15 +531,15 @@ td.section {
</g>
<!-- pollmessage_614a523e&#45;&gt;registrar_6e1503e3 -->
<g id="edge43" class="edge">
<title>pollmessage_614a523e:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M4385.96,-584.85C4342.62,-692.42 4119.17,-1408 4111,-1416 4051.03,-1474.73 4014.57,-1472 3930.62,-1472 3930.62,-1472 3930.62,-1472 607.25,-1472 431.9,-1472 336.59,-1497.65 229.25,-1359 207.04,-1330.31 237.64,-743.59 209.66,-670.36" /> <polygon fill="black" stroke="black" points="4388.1,-580.6 4396.62,-573.7 4390.5,-575.84 4392.45,-571.97 4392.45,-571.97 4392.45,-571.97 4390.5,-575.84 4388.58,-569.65 4388.1,-580.6" /> <polygon fill="black" stroke="black" points="4382.64,-580.34 4391.57,-584.84 4390.67,-586.62 4381.74,-582.12 4382.64,-580.34" /> <polyline fill="none" stroke="black" points="4387.55,-581.69 4385.3,-586.16" /> <polygon fill="black" stroke="black" points="200.1,-666.89 207.7,-660.38 209,-661.9 201.4,-668.41 200.1,-666.89" /> <polyline fill="none" stroke="black" points="203.25,-662.88 206.5,-666.67" /> <polygon fill="black" stroke="black" points="203.36,-670.68 210.95,-664.18 212.25,-665.7 204.66,-672.2 203.36,-670.68" /> <polyline fill="none" stroke="black" points="206.5,-666.67 209.76,-670.47" /> <text xml:space="preserve" text-anchor="start" x="2304.38" y="-1474.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_poll_message_registrar_id</text>
<title>pollmessage_614a523e:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M4385.96,-584.85C4342.62,-692.42 4119.17,-1408 4111,-1416 4051.03,-1474.73 4014.57,-1472 3930.62,-1472 3930.62,-1472 3930.62,-1472 607.25,-1472 431.9,-1472 336.58,-1497.66 229.25,-1359 206.95,-1330.19 237.77,-740.93 209.69,-667.39" /> <polygon fill="black" stroke="black" points="4388.1,-580.6 4396.62,-573.7 4390.5,-575.84 4392.45,-571.97 4392.45,-571.97 4392.45,-571.97 4390.5,-575.84 4388.58,-569.65 4388.1,-580.6" /> <polygon fill="black" stroke="black" points="4382.64,-580.34 4391.57,-584.84 4390.67,-586.62 4381.74,-582.12 4382.64,-580.34" /> <polyline fill="none" stroke="black" points="4387.55,-581.69 4385.3,-586.16" /> <polygon fill="black" stroke="black" points="200.1,-663.89 207.7,-657.38 209,-658.9 201.4,-665.41 200.1,-663.89" /> <polyline fill="none" stroke="black" points="203.25,-659.88 206.5,-663.67" /> <polygon fill="black" stroke="black" points="203.36,-667.68 210.95,-661.18 212.25,-662.7 204.66,-669.2 203.36,-667.68" /> <polyline fill="none" stroke="black" points="206.5,-663.67 209.76,-667.47" /> <text xml:space="preserve" text-anchor="start" x="2304.38" y="-1474.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_poll_message_registrar_id</text>
</g>
<!-- pollmessage_614a523e&#45;&gt;registrar_6e1503e3 -->
<g id="edge44" class="edge">
<title>pollmessage_614a523e:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M4376.88,-441.94C4374.48,-438.2 4371.72,-434.17 4366.75,-431 4261.03,-363.61 4231.82,-345.47 4111,-312 4033.32,-290.48 4011.24,-293 3930.62,-293 3930.62,-293 3930.62,-293 607.25,-293 523.12,-293 289.3,-255.08 229.25,-314 203.73,-339.05 236.05,-613.25 211.14,-657" /> <polygon fill="black" stroke="black" points="4384.24,-447.01 4389.92,-456.39 4388.63,-450.04 4392.2,-452.5 4392.2,-452.5 4392.2,-452.5 4388.63,-450.04 4395.03,-448.98 4384.24,-447.01" /> <ellipse fill="none" stroke="black" cx="4379.53" cy="-443.77" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="207.04,-666.29 201.07,-658.27 202.67,-657.07 208.64,-665.09 207.04,-666.29" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.26,-659.89" /> <polygon fill="black" stroke="black" points="211.05,-663.3 205.08,-655.28 206.68,-654.09 212.65,-662.11 211.05,-663.3" /> <polyline fill="none" stroke="black" points="207.26,-659.89 211.27,-656.9" /> <text xml:space="preserve" text-anchor="start" x="2220.75" y="-295.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_poll_message_transfer_response_gaining_registrar_id</text>
<title>pollmessage_614a523e:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M4376.88,-441.94C4374.48,-438.2 4371.72,-434.17 4366.75,-431 4261.03,-363.61 4231.82,-345.47 4111,-312 4033.32,-290.48 4011.24,-293 3930.62,-293 3930.62,-293 3930.62,-293 607.25,-293 523.12,-293 289.32,-255.1 229.25,-314 203.94,-338.82 235.81,-610.67 211.07,-654.05" /> <polygon fill="black" stroke="black" points="4384.24,-447.01 4389.92,-456.39 4388.63,-450.04 4392.2,-452.5 4392.2,-452.5 4392.2,-452.5 4388.63,-450.04 4395.03,-448.98 4384.24,-447.01" /> <ellipse fill="none" stroke="black" cx="4379.53" cy="-443.77" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="207.04,-663.29 201.07,-655.27 202.67,-654.07 208.64,-662.09 207.04,-663.29" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.26,-656.89" /> <polygon fill="black" stroke="black" points="211.05,-660.3 205.08,-652.28 206.68,-651.09 212.65,-659.11 211.05,-660.3" /> <polyline fill="none" stroke="black" points="207.26,-656.89 211.27,-653.9" /> <text xml:space="preserve" text-anchor="start" x="2220.75" y="-295.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_poll_message_transfer_response_gaining_registrar_id</text>
</g>
<!-- pollmessage_614a523e&#45;&gt;registrar_6e1503e3 -->
<g id="edge45" class="edge">
<title>pollmessage_614a523e:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M4374.69,-426.76C4358.35,-406.75 4399.85,-344.61 4366.75,-314 4223.1,-181.16 4126.28,-254 3930.62,-254 3930.62,-254 3930.62,-254 607.25,-254 523.09,-254 288.95,-217.69 229.25,-277 201.03,-305.04 239.61,-611.88 211.39,-657.28" /> <polygon fill="black" stroke="black" points="4383,-429.69 4390.94,-437.26 4388.03,-431.46 4392.12,-432.9 4392.12,-432.9 4392.12,-432.9 4388.03,-431.46 4393.93,-428.77 4383,-429.69" /> <ellipse fill="none" stroke="black" cx="4377.61" cy="-427.79" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="206.91,-666.43 201.24,-658.19 202.89,-657.06 208.55,-665.3 206.91,-666.43" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.37,-660.04" /> <polygon fill="black" stroke="black" points="211.03,-663.6 205.36,-655.36 207.01,-654.22 212.68,-662.46 211.03,-663.6" /> <polyline fill="none" stroke="black" points="207.37,-660.04 211.49,-657.21" /> <text xml:space="preserve" text-anchor="start" x="2224.88" y="-256.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_poll_message_transfer_response_losing_registrar_id</text>
<title>pollmessage_614a523e:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M4374.69,-426.76C4358.35,-406.75 4399.85,-344.61 4366.75,-314 4223.1,-181.16 4126.28,-254 3930.62,-254 3930.62,-254 3930.62,-254 607.25,-254 523.09,-254 288.97,-217.7 229.25,-277 201.24,-304.81 239.35,-609.28 211.33,-654.32" /> <polygon fill="black" stroke="black" points="4383,-429.69 4390.94,-437.26 4388.03,-431.46 4392.12,-432.9 4392.12,-432.9 4392.12,-432.9 4388.03,-431.46 4393.93,-428.77 4383,-429.69" /> <ellipse fill="none" stroke="black" cx="4377.61" cy="-427.79" rx="4" ry="4" /> <polygon fill="black" stroke="black" points="206.91,-663.43 201.24,-655.19 202.89,-654.06 208.55,-662.3 206.91,-663.43" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.37,-657.04" /> <polygon fill="black" stroke="black" points="211.03,-660.6 205.36,-652.36 207.01,-651.22 212.68,-659.46 211.03,-660.6" /> <polyline fill="none" stroke="black" points="207.37,-657.04 211.49,-654.21" /> <text xml:space="preserve" text-anchor="start" x="2224.88" y="-256.45" font-family="Helvetica,sans-Serif" font-size="14.00">fk_poll_message_transfer_response_losing_registrar_id</text>
</g>
<!-- domaindsdatahistory_995b060d -->
<g id="node22" class="node">
@@ -579,7 +579,7 @@ td.section {
</g>
<!-- hosthistory_56210c2&#45;&gt;registrar_6e1503e3 -->
<g id="edge42" class="edge">
<title>hosthistory_56210c2:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3767.77,-434.3C3652.11,-429.35 3627.65,-368.61 3505.25,-350 3116.46,-290.88 2128.88,-339 1735.62,-339 1735.62,-339 1735.62,-339 607.25,-339 437.73,-339 346.75,-267.81 229.25,-390 189.58,-431.25 255.26,-635.83 212.85,-660.45" /> <polygon fill="black" stroke="black" points="3772.79,-434.41 3782.7,-439.12 3778.13,-434.52 3782.46,-434.61 3782.46,-434.61 3782.46,-434.61 3778.13,-434.52 3782.88,-430.12 3772.79,-434.41" /> <polygon fill="black" stroke="black" points="3770.68,-429.36 3770.47,-439.36 3768.47,-439.32 3768.68,-429.32 3770.68,-429.36" /> <polyline fill="none" stroke="black" points="3771.57,-434.38 3766.58,-434.28" /> <polygon fill="black" stroke="black" points="205.44,-667.48 202.99,-657.78 204.93,-657.29 207.38,-666.99 205.44,-667.48" /> <polyline fill="none" stroke="black" points="203.25,-662.88 208.1,-661.65" /> <polygon fill="black" stroke="black" points="210.29,-666.25 207.84,-656.56 209.78,-656.07 212.23,-665.76 210.29,-666.25" /> <polyline fill="none" stroke="black" points="208.1,-661.65 212.95,-660.43" /> <text xml:space="preserve" text-anchor="start" x="1973.38" y="-340" font-family="Helvetica,sans-Serif" font-size="14.00">fk_history_registrar_id</text>
<title>hosthistory_56210c2:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M3767.77,-434.3C3652.11,-429.35 3627.65,-368.61 3505.25,-350 3116.46,-290.88 2128.88,-339 1735.62,-339 1735.62,-339 1735.62,-339 607.25,-339 437.73,-339 346.82,-267.87 229.25,-390 190,-430.78 254.72,-633.12 212.75,-657.48" /> <polygon fill="black" stroke="black" points="3772.79,-434.41 3782.7,-439.12 3778.13,-434.52 3782.46,-434.61 3782.46,-434.61 3782.46,-434.61 3778.13,-434.52 3782.88,-430.12 3772.79,-434.41" /> <polygon fill="black" stroke="black" points="3770.68,-429.36 3770.47,-439.36 3768.47,-439.32 3768.68,-429.32 3770.68,-429.36" /> <polyline fill="none" stroke="black" points="3771.57,-434.38 3766.58,-434.28" /> <polygon fill="black" stroke="black" points="205.44,-664.48 203,-654.78 204.93,-654.29 207.38,-663.99 205.44,-664.48" /> <polyline fill="none" stroke="black" points="203.25,-659.88 208.1,-658.65" /> <polygon fill="black" stroke="black" points="210.29,-663.25 207.84,-653.56 209.78,-653.07 212.23,-662.76 210.29,-663.25" /> <polyline fill="none" stroke="black" points="208.1,-658.65 212.95,-657.43" /> <text xml:space="preserve" text-anchor="start" x="1973.38" y="-340" font-family="Helvetica,sans-Serif" font-size="14.00">fk_history_registrar_id</text>
</g>
<!-- lock_f21d4861 -->
<g id="node28" class="node">
@@ -615,7 +615,7 @@ td.section {
</g>
<!-- registrarpoc_ab47054d&#45;&gt;registrar_6e1503e3 -->
<g id="edge46" class="edge">
<title>registrarpoc_ab47054d:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M482.84,-171.05C370.61,-168.16 305.95,-136.4 229.25,-224.25 199.27,-258.59 245.16,-611.11 211.66,-657.78" /> <polygon fill="black" stroke="black" points="487.92,-171.11 497.86,-175.75 493.25,-171.18 497.58,-171.24 497.58,-171.24 497.58,-171.24 493.25,-171.18 497.97,-166.75 487.92,-171.11" /> <polygon fill="black" stroke="black" points="485.76,-166.09 485.63,-176.09 483.63,-176.06 483.76,-166.06 485.76,-166.09" /> <polyline fill="none" stroke="black" points="486.7,-171.1 481.7,-171.03" /> <polygon fill="black" stroke="black" points="206.7,-666.63 201.52,-658.08 203.23,-657.04 208.41,-665.6 206.7,-666.63" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.53,-660.29" /> <polygon fill="black" stroke="black" points="210.97,-664.04 205.79,-655.49 207.5,-654.45 212.68,-663.01 210.97,-664.04" /> <polyline fill="none" stroke="black" points="207.53,-660.29 211.8,-657.7" /> <text xml:space="preserve" text-anchor="start" x="243.88" y="-226.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_registrar_poc_registrar_id</text>
<title>registrarpoc_ab47054d:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M482.84,-171.05C370.62,-168.16 305.97,-136.42 229.25,-224.25 199.47,-258.34 244.9,-608.47 211.6,-654.82" /> <polygon fill="black" stroke="black" points="487.92,-171.11 497.86,-175.75 493.25,-171.18 497.58,-171.24 497.58,-171.24 497.58,-171.24 493.25,-171.18 497.97,-166.75 487.92,-171.11" /> <polygon fill="black" stroke="black" points="485.76,-166.09 485.63,-176.09 483.63,-176.06 483.76,-166.06 485.76,-166.09" /> <polyline fill="none" stroke="black" points="486.7,-171.1 481.7,-171.03" /> <polygon fill="black" stroke="black" points="206.69,-663.63 201.52,-655.08 203.23,-654.04 208.41,-662.6 206.69,-663.63" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.53,-657.29" /> <polygon fill="black" stroke="black" points="210.97,-661.05 205.79,-652.49 207.5,-651.45 212.68,-660.01 210.97,-661.05" /> <polyline fill="none" stroke="black" points="207.53,-657.29 211.8,-654.7" /> <text xml:space="preserve" text-anchor="start" x="243.88" y="-226.7" font-family="Helvetica,sans-Serif" font-size="14.00">fk_registrar_poc_registrar_id</text>
</g>
<!-- registrarupdatehistory_8a38bed4 -->
<g id="node36" class="node">
@@ -623,7 +623,7 @@ td.section {
</g>
<!-- registrarupdatehistory_8a38bed4&#45;&gt;registrar_6e1503e3 -->
<g id="edge47" class="edge">
<title>registrarupdatehistory_8a38bed4:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M449.35,-83.97C351.02,-82.05 294.37,-63.91 229.25,-143.25 194.65,-185.4 253.64,-610.45 211.9,-658.47" /> <polygon fill="black" stroke="black" points="454.17,-84.02 464.12,-88.62 459.5,-84.07 463.83,-84.12 463.83,-84.12 463.83,-84.12 459.5,-84.07 464.21,-79.62 454.17,-84.02" /> <polygon fill="black" stroke="black" points="452,-79 451.9,-89 449.9,-88.98 450,-78.98 452,-79" /> <polyline fill="none" stroke="black" points="452.95,-84.01 447.95,-83.96" /> <polygon fill="black" stroke="black" points="206.41,-666.88 201.87,-657.97 203.65,-657.06 208.19,-665.97 206.41,-666.88" /> <polyline fill="none" stroke="black" points="203.25,-662.88 207.71,-660.61" /> <polygon fill="black" stroke="black" points="210.87,-664.61 206.33,-655.7 208.11,-654.79 212.65,-663.7 210.87,-664.61" /> <polyline fill="none" stroke="black" points="207.71,-660.61 212.16,-658.34" /> <text xml:space="preserve" text-anchor="start" x="229.25" y="-145.7" font-family="Helvetica,sans-Serif" font-size="14.00">fkregistrarupdatehistoryregistrarid</text>
<title>registrarupdatehistory_8a38bed4:w-&gt;registrar_6e1503e3:e</title> <path fill="none" stroke="black" d="M449.35,-83.97C351.02,-82.06 294.39,-63.91 229.25,-143.25 194.84,-185.16 253.36,-607.76 211.85,-655.49" /> <polygon fill="black" stroke="black" points="454.17,-84.02 464.12,-88.62 459.5,-84.07 463.83,-84.12 463.83,-84.12 463.83,-84.12 459.5,-84.07 464.21,-79.62 454.17,-84.02" /> <polygon fill="black" stroke="black" points="452,-79 451.9,-89 449.9,-88.98 450,-78.98 452,-79" /> <polyline fill="none" stroke="black" points="452.95,-84.01 447.95,-83.96" /> <polygon fill="black" stroke="black" points="206.41,-663.88 201.87,-654.97 203.65,-654.06 208.19,-662.97 206.41,-663.88" /> <polyline fill="none" stroke="black" points="203.25,-659.88 207.71,-657.61" /> <polygon fill="black" stroke="black" points="210.87,-661.61 206.33,-652.7 208.11,-651.79 212.65,-660.7 210.87,-661.61" /> <polyline fill="none" stroke="black" points="207.71,-657.61 212.16,-655.34" /> <text xml:space="preserve" text-anchor="start" x="229.25" y="-145.7" font-family="Helvetica,sans-Serif" font-size="14.00">fkregistrarupdatehistoryregistrarid</text>
</g>
<!-- registrarpocupdatehistory_31e5d9aa -->
<g id="node37" class="node">
@@ -4378,6 +4378,11 @@ td.section {
<td class="minwidth"><b><i>email_address</i></b></td>
<td class="minwidth">text not null</td>
</tr>
<tr>
<td class="spacer"></td>
<td class="minwidth">registry_lock_email_address</td>
<td class="minwidth">text</td>
</tr>
<tr>
<td colspan="3"></td>
</tr>
File diff suppressed because one or more lines are too long
+1
View File
@@ -222,3 +222,4 @@ V221__remove_contact_history.sql
V222__remove_contact.sql
V223__tld_change_xap_enabled_to_transitions.sql
V224__add_registrar_expiry_access_period_enabled.sql
V225__user_registry_lock_email_address_index.sql
@@ -0,0 +1,16 @@
-- Copyright 2026 The Nomulus Authors. All Rights Reserved.
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
CREATE INDEX CONCURRENTLY IF NOT EXISTS user_registry_lock_email_address_idx ON "User"
USING hash (registry_lock_email_address);
@@ -43,7 +43,7 @@
domain_repo_id text not null,
event_time timestamp(6) with time zone not null,
flags text[],
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','FEE_EXPIRY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
domain_name text not null,
billing_event_id bigint,
billing_recurrence_id bigint,
@@ -58,7 +58,7 @@
domain_repo_id text not null,
event_time timestamp(6) with time zone not null,
flags text[],
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','FEE_EXPIRY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
domain_name text not null,
allocation_token text,
billing_time timestamp(6) with time zone,
@@ -78,7 +78,7 @@
domain_repo_id text not null,
event_time timestamp(6) with time zone not null,
flags text[],
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','FEE_EXPIRY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
domain_name text not null,
recurrence_end_time timestamp(6) with time zone,
recurrence_last_expansion timestamp(6) with time zone not null,
@@ -507,6 +507,7 @@
creation_time timestamp(6) with time zone not null,
drive_folder_id text,
email_address text,
expiry_access_period_enabled boolean not null,
failover_client_certificate text,
failover_client_certificate_hash text,
fax_number text,
@@ -646,6 +647,7 @@
drive_folder_id text,
eap_fee_schedule hstore not null,
escrow_enabled boolean not null,
expiry_access_period_transitions hstore not null,
idn_tables text[],
invoicing_enabled boolean not null,
lordn_username text,
@@ -2,8 +2,8 @@
-- PostgreSQL database dump
--
-- Dumped from database version 17.4
-- Dumped by pg_dump version 17.4
-- Dumped from database version 17.10
-- Dumped by pg_dump version 17.10
SET statement_timeout = 0;
SET lock_timeout = 0;
@@ -2611,6 +2611,13 @@ CREATE INDEX spec11threatmatch_registrar_id_idx ON public."Spec11ThreatMatch" US
CREATE INDEX spec11threatmatch_tld_idx ON public."Spec11ThreatMatch" USING btree (tld);
--
-- Name: user_registry_lock_email_address_idx; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX user_registry_lock_email_address_idx ON public."User" USING hash (registry_lock_email_address);
--
-- Name: Domain fk2jc69qyg2tv9hhnmif6oa1cx1; Type: FK CONSTRAINT; Schema: public; Owner: -
--