Shipmind Labs

A route no link can satisfy should fail when the table is built

· 10 min read

A deep link routing table decides which screen a stranger's URL reaches, which makes it an authorization boundary. It has a failure mode an ordinary switch statement does not: a declared route that no link can ever satisfy looks, from the outside, exactly like a route that works. A constraint that refuses every value, an allow-list entry that cannot mean what it says, or a route open only to a door the app does not have each fail quietly, one link at a time, in the field.

We maintain deeplinkmap with the opposite bias. The table refuses its own author first, at construction, with the specific contradiction named, and keeps runtime refusals for links alone.

Why an unreachable route hides#

A link router is built to refuse things. That is the whole point: a URL arrives from an email, a text message, another app, or a QR code on a poster, and most of what arrives should go nowhere. In deeplinkmap a refused link comes back as a value with a reason — no-route, bad-parameter, foreign-host, unknown-scheme, insecure-scheme, form-not-accepted.

Now suppose a route declares { kind: "number", min: 10, max: 1 }. Every link to that path is refused with bad-parameter. That is a correct-looking refusal for a bad value, and it is the same refusal a genuinely malformed link earns. Nothing in the per-link signal distinguishes "this value is wrong" from "no value could ever be right."

This is why the bug survives. Refusals are expected traffic, so a refusal is not a page-out. The route works in the developer's head, the screen exists, QA taps the link from a staging email and it does nothing, and the first explanation anyone reaches for is the link, the mail client, or the association file — not the four characters in the table that made the route a dead end. A boundary whose gaps are invisible in its own logs has to be checked somewhere else.

Two audiences, two vocabularies#

The split we settled on is that a link earns a refusal; an unreadable table is handed back to its author. Those are different error channels, not different severities of the same one.

  • InvalidPolicy — the router-wide settings cannot be an allow-list: a host or scheme pattern that cannot mean what it says, an empty accepts, an unknown LinkForm.
  • InvalidRoute — a specific route cannot be read unambiguously, or can never open.
  • Refusal — a link did not qualify. Returned, never thrown.

Because the first two throw from the Router constructor, a contradictory table does not degrade an app; it fails to start one. That is a deliberate trade, and we will come back to what it costs.

Constraints that refuse everything#

Every ParamSpec goes through check before any link reaches it. The checks are not type validation — TypeScript already did that — they are checks on what the declaration means:

typescript
function check(route: string, where: string, declaration: ParamSpec): void {
  const fail = (detail: string): never => {
    throw new InvalidRoute(`route "${route}" parameter ${where} ${detail}`);
  };

  if (!(declaration.kind in PATTERNS)) fail(`has unknown kind "${declaration.kind}"`);

  const minLength = declaration.minLength ?? DEFAULT_MIN_LENGTH;
  const maxLength = declaration.maxLength ?? DEFAULT_MAX_LENGTH;
  if (minLength < 1) fail(`has minLength ${minLength}, below one`);
  if (maxLength < minLength) fail(`has maxLength ${maxLength} below minLength ${minLength}`);

  const bounded = declaration.min !== undefined || declaration.max !== undefined;
  if (bounded && declaration.kind !== "number") {
    fail(`has min/max, which only apply to a number, not a ${declaration.kind}`);
  }
  if (
    declaration.min !== undefined &&
    declaration.max !== undefined &&
    declaration.min > declaration.max
  ) {
    fail(`has min ${declaration.min} above max ${declaration.max}`);
  }

  if (declaration.oneOf) {
    if (declaration.secret) {
      fail("is secret and lists the complete set of values it is drawn from in oneOf");
    }
    if (declaration.oneOf.length === 0) fail("has an empty oneOf, so nothing can match");
    for (const value of declaration.oneOf) {
      if (validate(String(value), { ...declaration, oneOf: undefined }) === null) {
        fail(`lists ${JSON.stringify(value)} in oneOf, which its own kind and limits refuse`);
      }
    }
  }
}

Two things in there are worth separating out.

The oneOf loop runs the same validate the router runs on a real link. The build-time check and the runtime check cannot drift, because there is only one of them. A set like { kind: "number", oneOf: ["pdf"] } is refused at construction for exactly the reason the link would have been refused at runtime, and the message says so.

The secret rule is not a contradiction in arithmetic at all. A refusal names the set it refused against, and a value drawn from a set written in the table is not a secret in the first place — so secret together with oneOf is a declaration that means two incompatible things, and it is refused on meaning rather than on shape. That is the kind of check a type system will not do for you.

The tests pin the whole class of them in one table:

typescript
test("a constraint that contradicts itself is a build-time error", () => {
  const impossible = [
    { name: "range", pattern: "/a/:n", params: { n: { kind: "number", min: 10, max: 1 } } },
    { name: "bounded-slug", pattern: "/b/:n", params: { n: { kind: "slug", min: 1 } } },
    { name: "wrong-set", pattern: "/c/:n", params: { n: { kind: "number", oneOf: ["pdf"] } } },
    {
      name: "lengths",
      pattern: "/d/:n",
      params: { n: { kind: "string", minLength: 9, maxLength: 4 } },
    },
    { name: "empty-set", pattern: "/e/:n", params: { n: { kind: "string", oneOf: [] } } },
    { name: "query", pattern: "/f", query: { q: { kind: "number", min: 3, max: 2 } } },
  ] satisfies Route[];

  for (const route of impossible) {
    assert.throws(() => new Router([route], { hosts: ["example.com"] }), InvalidRoute, route.name);
  }
});

Allow-lists that cannot mean what they say#

The host and scheme lists get the same treatment, and here the argument is sharper, because an allow-list that cannot mean what it says is worse than no allow-list at all: it reads like a boundary in review while letting links through, or while refusing every one of them.

compileHost throws InvalidPolicy for an empty value, a bare *, URL punctuation such as a colon or a slash, a wildcard anywhere but the first label, a wildcard over a whole top-level domain such as *.com, and a label that fails the label pattern. compileScheme refuses http and https outright — those are not schemes to allow, they are a question the host allow-list answers.

typescript
for (const host of [
  "",
  "*",
  "*.com",
  "a.*.example.com",
  "https://example.com",
  "example.com:8080",
  "exa mple.com",
]) {
  assert.throws(() => new Router([], { hosts: [host] }), InvalidPolicy, host);
}
for (const scheme of ["", "http", "https", "my app", "myapp://", "1app"]) {
  assert.throws(() => new Router([], { schemes: [scheme] }), InvalidPolicy, scheme);
}

Note that these split in two directions. * and *.com are too permissive to be a boundary; https://example.com and example.com:8080 are entries that will simply never match a hostname, so they are a boundary that is quietly absent. Both get the same treatment, because both make the table unreadable: somebody reviewing it would conclude something false either way.

Doors the app does not have#

The last check can only run once everything else is known, so it runs last. A route's accepts defaults to ['universal'], and goes through accepted, which throws InvalidPolicy for an empty list or an unknown LinkForm. Then unreachable compares what each route opens to against what the router actually has: universal with no hosts, scheme with no schemes, insecure without allowInsecure or hosts. A route whose only doors the router lacks throws InvalidRoute saying it can never open.

typescript
test("a route with no door it can open is a build-time error", () => {
  assert.throws(() => new Router([{ name: "home", pattern: "/" }]), InvalidRoute);
  assert.throws(
    () =>
      new Router([{ name: "home", pattern: "/", accepts: ["scheme"] }], {
        hosts: ["example.com"],
      }),
    InvalidRoute,
  );
});

The first case is the one that bites in practice. { name: "home", pattern: "/" } is a complete, obviously correct-looking route. It defaults to universal, the router was given no hosts, and so nothing can ever reach it. Without the check, the app ships, every link is refused foreign-host or no-route, and the table that was supposed to make the boundary reviewable reads as if the boundary were there.

The ordering is doing work here, too. Hosts, schemes and forms are compiled first; then each route is checked in order for a duplicate name, a repeated placeholder, a placeholder absent from params, and a params key with no placeholder; then reachability. Each stage is named for the thing it can name. A reachability error raised before the host list was validated could only say "this route cannot open," when the real answer is "this host pattern is not a host."

The same rule applies around the table#

The policy is not special to routes. LinkIntake dedupes repeated deliveries within a window, and it refuses its own configuration on the same grounds:

typescript
if (!Number.isFinite(dedupeWithinMs) || dedupeWithinMs < 1) {
  throw new InvalidIntake(
    `dedupeWithinMs ${dedupeWithinMs} is not a window a link can be remembered for`,
  );
}

Zero remembers nothing, so every repeat is applied. A window with no end makes a link tapped again an hour later do nothing at all. Both read as dedupe and behave as something else, which is the identical failure shape: a setting that is accepted, looks like a control, and is not one.

What it costs to run#

The build gets stricter in ways you feel.

Every placeholder must declare its kind, and a route with an undeclared placeholder fails at construction — so there is no quick route you can add and tighten later. A table edit can stop the app from starting rather than degrade one screen, which means the table belongs under the same review gate as a migration, and a bad merge is a startup crash instead of a dead link. There is no flag to relax this; allowInsecure is not a door, only permission to read an http link at all, and a route still has to accept the insecure form explicitly.

The price is paid in exchange for one property: the table can be reviewed by reading it. If a route is in the table, a link can reach it, the parameters it names are the parameters the screen receives, and the doors it lists are doors the app actually has. Everything else happens per link, as a returned Refusal, where the decision belongs.

The error messages are the part that has to be maintained. has min 10 above max 1 is useful; "invalid route" would be a worse version of the silence we were trying to remove, since it moves the mystery from runtime to startup without answering anything. Naming the contradiction is most of the value, and it is the part that quietly rots if nobody keeps writing it.

We would take this trade again. A boundary nobody can read is one nobody can review, and a gap in it is a gap in the only place links are decided.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com