DEV Community

Cover image for Build a Safe Service Entry Gateway in TypeScript
FinClip Super-App
FinClip Super-App

Posted on

Build a Safe Service Entry Gateway in TypeScript

Build a Safe Service Entry Gateway in TypeScript

A customer should not need a new standalone app for every focused digital service. An appointment, campaign, local offer, claim intake, or partner benefit may fit inside an app the customer already trusts. A secure web journey can also be enough when no deep integration is required.

Removing the extra download creates a new engineering responsibility: the organisation needs a safe way to decide where a service opens and which host capabilities travel with the launch.

This tutorial builds a small TypeScript service-entry gateway. It supports two destinations:

  • An embedded module running inside an approved host app.
  • An allowlisted HTTPS fallback for browsers or unsupported host versions.

The gateway validates a versioned service manifest, chooses an eligible route, creates a short-lived signed launch ticket, and returns reasons that can be logged and reviewed. It does not send raw customer records through a URL.

Start with a service manifest

The service producer should declare what the service needs before it can be launched:

export type Capability =
  | "identity.basic"
  | "payment.initiate"
  | "location.approximate"
  | "camera.capture"
  | "analytics.event";

export interface EmbeddedEntry {
  kind: "embedded";
  moduleId: string;
  minimumHostVersion: string;
}

export interface WebEntry {
  kind: "web";
  url: string;
}

export interface ServiceManifest {
  serviceId: string;
  version: string;
  displayName: string;
  owner: string;
  status: "draft" | "active" | "suspended" | "retired";
  allowedMarkets: string[];
  requiredCapabilities: Capability[];
  entries: Array<EmbeddedEntry | WebEntry>;
  supportUrl: string;
}
Enter fullscreen mode Exit fullscreen mode

Keep the manifest small and auditable. It is a contract between the service, host, and gateway, not a place for marketing copy or customer data.

The status field gives operations an independent stop control. A suspended service should stop receiving new launches even if its code remains deployed. allowedMarkets prevents a service approved in one jurisdiction from appearing everywhere by default.

A production manifest would also include data purpose, age classification, risk tier, signing identity, review evidence, and expiry dates. Add fields when the corresponding control exists; decorative metadata can create false confidence.

Describe the launch context

The gateway needs enough context to choose a route without collecting unnecessary information:

export interface LaunchRequest {
  serviceId: string;
  market: string;
  channel: "host_app" | "mobile_web" | "desktop_web";
  hostVersion?: string;
  subjectReference?: string;
  requestedCapabilities: Capability[];
}

export interface LaunchDecision {
  destination: "embedded" | "web" | "deny";
  target?: string;
  ticket?: string;
  reasons: string[];
}
Enter fullscreen mode Exit fullscreen mode

subjectReference should be an opaque identifier understood by the identity broker, not an email address, phone number, account balance, or full customer profile. The receiving service can exchange a valid reference for the minimum data it is authorised to use.

Avoid putting sensitive attributes in query parameters. URLs are copied into analytics, browser history, reverse-proxy logs, screenshots, and support tickets more often than teams expect.

Validate URLs and manifest policy

The gateway should reject malformed or overly broad destinations before any route selection happens:

const ALLOWED_WEB_HOSTS = new Set([
  "services.example.com",
  "partners.example.net",
]);

const HOST_CAPABILITIES = new Set<Capability>([
  "identity.basic",
  "payment.initiate",
  "location.approximate",
  "camera.capture",
  "analytics.event",
]);

function isAllowedHttpsUrl(value: string): boolean {
  try {
    const url = new URL(value);
    return url.protocol === "https:" && ALLOWED_WEB_HOSTS.has(url.hostname);
  } catch {
    return false;
  }
}

export function validateManifest(manifest: ServiceManifest): string[] {
  const errors: string[] = [];

  if (!/^[a-z0-9][a-z0-9.-]{2,63}$/.test(manifest.serviceId)) {
    errors.push("serviceId has an invalid format");
  }

  if (!manifest.owner.trim()) errors.push("owner is required");
  if (manifest.allowedMarkets.length === 0) {
    errors.push("at least one market is required");
  }

  if (manifest.entries.length === 0) errors.push("an entry is required");

  for (const capability of manifest.requiredCapabilities) {
    if (!HOST_CAPABILITIES.has(capability)) {
      errors.push(`unsupported capability: ${capability}`);
    }
  }

  for (const entry of manifest.entries) {
    if (entry.kind === "web" && !isAllowedHttpsUrl(entry.url)) {
      errors.push(`web entry is not allowlisted: ${entry.url}`);
    }
  }

  if (!isAllowedHttpsUrl(manifest.supportUrl)) {
    errors.push("supportUrl must use an allowlisted HTTPS host");
  }

  return errors;
}
Enter fullscreen mode Exit fullscreen mode

Use exact hostnames instead of suffix checks such as endsWith("example.com"). A misleading domain like notexample.com can pass a careless suffix rule. If subdomains are allowed, parse and compare them against an explicit policy.

Validation should happen when a manifest is submitted and again when it is activated. Store the policy version with the result so an old approval remains interpretable after allowlists change.

Compare semantic versions

An embedded module may depend on APIs introduced in a newer host. A simple semantic-version comparison is sufficient for this example:

function compareVersions(left: string, right: string): number {
  const a = left.split(".").map(Number);
  const b = right.split(".").map(Number);
  const length = Math.max(a.length, b.length);

  for (let index = 0; index < length; index += 1) {
    const difference = (a[index] ?? 0) - (b[index] ?? 0);
    if (difference !== 0) return Math.sign(difference);
  }

  return 0;
}
Enter fullscreen mode Exit fullscreen mode

Real semantic versions can include prerelease labels and build metadata. Use a maintained parser in production and define how prerelease host versions are treated. An unexpected version string should fail closed or use the web fallback; it should not be interpreted as the newest possible client.

Issue a short-lived launch ticket

The service needs proof that the gateway approved the launch. We can create a compact payload and sign it with an HMAC. Node’s built-in crypto module is enough for the example:

import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";

interface TicketPayload {
  iss: "service-entry-gateway";
  aud: string;
  serviceId: string;
  serviceVersion: string;
  subjectReference?: string;
  market: string;
  capabilities: Capability[];
  iat: number;
  exp: number;
  nonce: string;
}

function base64url(value: string): string {
  return Buffer.from(value, "utf8").toString("base64url");
}

function signPayload(payload: TicketPayload, secret: string): string {
  const body = base64url(JSON.stringify(payload));
  const signature = createHmac("sha256", secret)
    .update(body)
    .digest("base64url");

  return `${body}.${signature}`;
}

function createTicket(
  manifest: ServiceManifest,
  request: LaunchRequest,
  audience: string,
  secret: string,
  now = new Date(),
): string {
  const issuedAt = Math.floor(now.getTime() / 1000);

  return signPayload(
    {
      iss: "service-entry-gateway",
      aud: audience,
      serviceId: manifest.serviceId,
      serviceVersion: manifest.version,
      subjectReference: request.subjectReference,
      market: request.market,
      capabilities: request.requestedCapabilities,
      iat: issuedAt,
      exp: issuedAt + 120,
      nonce: randomUUID(),
    },
    secret,
  );
}
Enter fullscreen mode Exit fullscreen mode

The two-minute lifetime limits replay exposure. The receiving service should also store consumed nonces until expiry when a ticket is intended for one-time use.

HMAC means the issuer and verifier share a secret. For a partner ecosystem, asymmetric signatures are often easier to separate operationally: the gateway keeps the private key and services receive only the public key. Use a standard token library and managed key service rather than inventing a production token format from this example.

Do not grant capabilities merely because the request asks for them. The request must be a subset of the reviewed manifest.

Choose the route

Now enforce status, market, and capability policy before selecting embedded or web delivery:

function isCapabilitySubset(
  requested: Capability[],
  approved: Capability[],
): boolean {
  const allowed = new Set(approved);
  return requested.every((capability) => allowed.has(capability));
}

export function decideLaunch(
  manifest: ServiceManifest,
  request: LaunchRequest,
  signingSecret: string,
  now = new Date(),
): LaunchDecision {
  const errors = validateManifest(manifest);
  if (errors.length > 0) {
    return { destination: "deny", reasons: errors };
  }

  if (manifest.status !== "active") {
    return {
      destination: "deny",
      reasons: [`service status is ${manifest.status}`],
    };
  }

  if (!manifest.allowedMarkets.includes(request.market)) {
    return {
      destination: "deny",
      reasons: [`service is unavailable in ${request.market}`],
    };
  }

  if (
    !isCapabilitySubset(
      request.requestedCapabilities,
      manifest.requiredCapabilities,
    )
  ) {
    return {
      destination: "deny",
      reasons: ["requested capabilities exceed the reviewed manifest"],
    };
  }

  const embedded = manifest.entries.find(
    (entry): entry is EmbeddedEntry => entry.kind === "embedded",
  );

  if (
    request.channel === "host_app" &&
    request.hostVersion &&
    embedded &&
    compareVersions(request.hostVersion, embedded.minimumHostVersion) >= 0
  ) {
    return {
      destination: "embedded",
      target: embedded.moduleId,
      ticket: createTicket(
        manifest,
        request,
        embedded.moduleId,
        signingSecret,
        now,
      ),
      reasons: ["host version and capability policy allow embedded launch"],
    };
  }

  const web = manifest.entries.find(
    (entry): entry is WebEntry => entry.kind === "web",
  );

  if (web) {
    return {
      destination: "web",
      target: web.url,
      ticket: createTicket(manifest, request, web.url, signingSecret, now),
      reasons: ["using approved HTTPS fallback"],
    };
  }

  return {
    destination: "deny",
    reasons: ["no eligible delivery route is available"],
  };
}
Enter fullscreen mode Exit fullscreen mode

Route selection prefers the embedded service only when the request comes from the host app and the host version meets the declared minimum. Older versions receive the approved web fallback. A service with no fallback is denied cleanly.

The gateway returns the ticket separately from the target. The client can transfer it in an authorised POST request or platform-specific secure handoff. Avoid appending it to the query string.

Verify the ticket

The receiving service must check signature, audience, expiry, and status before exchanging the subject reference:

function verifyTicket(
  token: string,
  expectedAudience: string,
  secret: string,
  now = new Date(),
): TicketPayload {
  const [body, suppliedSignature] = token.split(".");
  if (!body || !suppliedSignature) throw new Error("malformed ticket");

  const expectedSignature = createHmac("sha256", secret)
    .update(body)
    .digest("base64url");

  const supplied = Buffer.from(suppliedSignature);
  const expected = Buffer.from(expectedSignature);

  if (
    supplied.length !== expected.length ||
    !timingSafeEqual(supplied, expected)
  ) {
    throw new Error("invalid signature");
  }

  const payload = JSON.parse(
    Buffer.from(body, "base64url").toString("utf8"),
  ) as TicketPayload;

  const currentTime = Math.floor(now.getTime() / 1000);
  if (payload.exp <= currentTime) throw new Error("ticket expired");
  if (payload.aud !== expectedAudience) throw new Error("wrong audience");

  return payload;
}
Enter fullscreen mode Exit fullscreen mode

JSON parsing still needs runtime schema validation. A valid signature proves that the issuer signed the bytes; it does not prove the decoded object matches your expected shape.

The service should also confirm that its manifest version remains active. A ticket issued moments before an emergency suspension should not guarantee access for the rest of its lifetime if the risk warrants immediate revocation.

Test the important boundaries

Use Vitest to cover the success path and the controls most likely to regress:

import { describe, expect, it } from "vitest";
import { decideLaunch, ServiceManifest } from "./gateway";

const manifest: ServiceManifest = {
  serviceId: "appointments.branch",
  version: "1.4.0",
  displayName: "Branch appointments",
  owner: "customer-operations",
  status: "active",
  allowedMarkets: ["GB", "SG"],
  requiredCapabilities: ["identity.basic", "analytics.event"],
  entries: [
    {
      kind: "embedded",
      moduleId: "miniapp://appointments.branch/1.4.0",
      minimumHostVersion: "8.2.0",
    },
    { kind: "web", url: "https://services.example.com/appointments" },
  ],
  supportUrl: "https://services.example.com/support/appointments",
};

describe("service entry gateway", () => {
  it("launches inside an eligible host", () => {
    const result = decideLaunch(
      manifest,
      {
        serviceId: manifest.serviceId,
        market: "GB",
        channel: "host_app",
        hostVersion: "8.3.1",
        subjectReference: "subject_opaque_92",
        requestedCapabilities: ["identity.basic"],
      },
      "test-secret",
    );

    expect(result.destination).toBe("embedded");
    expect(result.ticket).toBeDefined();
  });

  it("uses web fallback for an older host", () => {
    const result = decideLaunch(
      manifest,
      {
        serviceId: manifest.serviceId,
        market: "GB",
        channel: "host_app",
        hostVersion: "8.1.9",
        requestedCapabilities: [],
      },
      "test-secret",
    );

    expect(result.destination).toBe("web");
  });

  it("denies a capability that was not reviewed", () => {
    const result = decideLaunch(
      manifest,
      {
        serviceId: manifest.serviceId,
        market: "GB",
        channel: "host_app",
        hostVersion: "8.3.1",
        requestedCapabilities: ["camera.capture"],
      },
      "test-secret",
    );

    expect(result.destination).toBe("deny");
  });

  it("denies a suspended service", () => {
    const result = decideLaunch(
      { ...manifest, status: "suspended" },
      {
        serviceId: manifest.serviceId,
        market: "GB",
        channel: "mobile_web",
        requestedCapabilities: [],
      },
      "test-secret",
    );

    expect(result.destination).toBe("deny");
  });
});
Enter fullscreen mode Exit fullscreen mode

Add tests for market restrictions, malformed versions, disallowed hosts, expired tickets, replayed nonces, and audience mismatch. Security tests should fail closed when required evidence is absent.

Treat the fallback as a supported journey

A web fallback is useful only when it remains part of the product, rather than a forgotten URL attached to the manifest. Give the page the same service identity, accessibility standard, analytics vocabulary, and support route as the embedded version. Customers should understand that they are continuing the same task even when the rendering environment changes.

Preserve as little state as possible during the transition. The launch ticket can carry an opaque subject reference and a narrow purpose, while the destination retrieves authorised data through its own backend. Avoid copying form contents, personal details, or durable credentials into query parameters. Browser history, proxy logs, screenshots, and referrer headers can expose more than the original developer expected.

Define what happens when the destination is unavailable. A controlled maintenance response with a retry path and support reference is more useful than sending the customer through repeated redirects. Apply a short timeout to dependency checks, and keep the deny response explainable enough for support teams to identify whether the cause was market policy, host compatibility, service status, or a technical failure.

The embedded and web routes should also share an idempotency strategy. If a customer presses the launch button twice, returns from the browser, or retries after a weak connection, the service must not create duplicate appointments, claims, or payments. Generate the business-operation idempotency key at the service boundary; do not rely on the launch-ticket nonce for this purpose. The nonce limits replay of entry context, while an idempotency key protects the transaction itself.

Finally, test the handoff on real mobile conditions. Slow networks, expired sessions, disabled cookies, older host versions, and interrupted authentication often reveal more than desktop happy-path testing. The route is successful only when the customer can complete the intended job and recover from predictable interruptions.

Operate the gateway as a product boundary

The gateway centralises a decision that otherwise spreads across deep links, web redirects, mobile code, and service-specific integrations. Give it an owner, availability target, audit trail, and emergency process.

Log the service ID, version, chosen destination, policy version, reason, and outcome. Do not log the raw ticket or customer attributes. Metrics should distinguish a clean fallback from an error so an old host version does not look like a failed launch.

Monitor completion after routing. An embedded destination may remove an installation but still present a confusing consent screen. A web fallback may be entirely adequate for a low-frequency task. Customer outcome should decide whether a route is useful.

Roll out new manifests to a small audience first. A feature flag can limit the market, customer segment, or percentage of traffic. The independent suspended status provides a fast response when support, compliance, or reliability problems appear.

One service, several proportionate routes

A safe entry gateway does not decide that every service belongs inside the host app. It gives the organisation a controlled way to support more than one route.

The manifest makes ownership and capability needs visible. Route policy respects host compatibility and market approval. Short-lived signed tickets transfer minimal launch context. The web fallback prevents an unnecessary download when the embedded experience is unavailable.

That combination allows a focused service to reach customers without immediately becoming another standalone application. If the service later demonstrates frequent use, independent brand value, and deeper native needs, its delivery model can evolve. The first implementation remains reversible while the evidence is still developing.

Top comments (0)