Skip to content
GitHubGet started

Well-known files, SEO and routing

REST API

How the worker serves the Apple and Android association files, robots.txt, the sitemap and blog post previews, and which host and path reaches which handler.

The DecTog worker answers every host in the zone: the site host (dectog.com, the apex, SITE_URL), the dashboard host (app.dectog.com, APP_HOST) and every account host (<username>.dectog.com). This page lists what each host and path does. Self-hosted deployments substitute their own ROOT_DOMAIN, SITE_URL, APP_HOST, WEB_URL and PUBLIC_BASE_URL; see Configuration reference.

Universal Links and App Links need two files on the account host. The worker generates both from the apps registered in Dashboard: Apps; nothing is uploaded. Setup on the app side is in Platform setup.

Path Built from
/.well-known/apple-app-site-association Every app of the account that has both ios_team_id and ios_bundle_id.
/.well-known/assetlinks.json Every app of the account that has android_package. Fingerprints come from android_sha256_fingerprints and default to [], which never verifies.
  1. A ?u=<username> query parameter always wins. This is how the dashboard’s Verify buttons fetch the file on the app host, and the only form that works in path mode or on localhost.
  2. Otherwise, when ROOT_DOMAIN does not start with localhost, the first label of the Host header: acme.dectog.com serves the account acme.
  3. Otherwise: 400 {"error": {"code": "invalid_request", "message": "Subdomain not found in host"}}.

An unknown username, or a known one with no qualifying app, answers 404 {"error": {"code": "not_found", "message": "Configuration not found"}}.

Both files are served with content-type: application/json; charset=utf-8 and three extra headers: x-hostname, the request’s Host header, x-subdomain, the resolved username (or the first host label when none was resolved), and access-control-allow-origin: *, so the dashboard’s setup checklist can read them from your live host.

https://acme.dectog.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{ "appIDs": ["8H7C9JL2K3.com.example.app"], "components": [{ "/": "/*" }] }
]
},
"webcredentials": { "apps": ["8H7C9JL2K3.com.example.app"] }
}

One details entry per qualifying app; webcredentials.apps lists the same app ids, so password AutoFill can be associated too. Every path on the host is claimed (/*).

https://acme.dectog.com/.well-known/assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AA:BB:CC:…"]
}
}
]

One statement per qualifying app. Include the Play App Signing certificate’s fingerprint, not only your upload key’s, or verification fails on devices that installed from Play.

Check what a host serves
curl -si https://acme.dectog.com/.well-known/apple-app-site-association
curl -s 'https://app.dectog.com/.well-known/assetlinks.json?u=acme'

Both are generated by the worker on every host because the blog lives in the database, not in the static export.

GET /robots.txt (text/plain):

User-agent: *
Allow: /
Disallow: /dashboard
Disallow: /api
Sitemap: https://dectog.com/sitemap.xml

GET /sitemap.xml (application/xml) lists SITE_URL plus /, /about, /blog and /contact, then /blog/<slug> for every published post, newest first, with <lastmod> set to the post’s updated_at date (YYYY-MM-DD). Drafts are never listed. The origin is SITE_URL (https://dectog.com on the hosted service), the host that serves those pages; the dashboard host sends them there with a permanent redirect.

The static export has no page per post. /blog/<slug> on the site host is answered with the export’s article page, /blog/article, which loads the post in the browser; /blog/ is the blog itself. The article page’s head has no title, description or share tags of its own. Link unfurlers never run the page’s script, so for a published post the worker adds them to the head before sending the page:

Tag Value
<title> <post title> — DecTog Blog
description, og:description, twitter:description The post’s excerpt. Left out when the excerpt is empty.
og:title, twitter:title The post’s title.
og:url SITE_URL plus /blog/<slug>, the address the sitemap lists.
og:site_name, og:type DecTog, article
og:image, twitter:image The post’s featured image, else the site card SITE_URL/media/og-card.png with its og:image:width (1200), og:image:height (630) and og:image:alt.
twitter:card summary_large_image

The head also gets a canonical link to the same og:url address, and one BlogPosting JSON-LD block with the headline, the excerpt as description (when there is one), the address, the image, datePublished, dateModified (the post’s updated_at) and DecTog as author and publisher.

Each added tag carries a data-post-head attribute; the page removes them when the visitor moves on to another page of the app. The page is sent without the export’s ETag.

Search and AI crawlers that run no script would find no article in the page, so the worker also writes the post’s title as an <h1>, followed by its body, into the page’s loading state. The body keeps the editor’s markup (headings, paragraphs, lists, links, images, tables, code) and loses scripts, styles, frames, embedded objects, media, SVG, forms, every attribute outside a short list (so every event handler), and any href or src that is not http, https, mailto or relative. Once the post has loaded in the browser, the page shows it in place of the loading state.

Drafts and unknown slugs get the article page unchanged, with status 404, so search engines drop the address. The page still loads, fails to find the post, shows “Article Not Found” and titles itself Article Not Found — DecTog Blog. A HEAD request gets the status a GET would: 200 with the export’s headers for a published post, 404 otherwise. A path under /blog/ that cannot be a slug, such as /blog/__next._tree.txt, one of the files the browser’s router fetches, is served from the static export as it is, or answers 404 when the export has no such file.

The exported /blog page has its own title and share tags but loads the list of posts in the browser. On a GET, the worker writes the published posts into the page’s loading state, newest first and at most 50: each post’s title as a link to /blog/<slug>, its excerpt when it has one, and its publication date (Oct 11, 2026, in UTC). The page is then sent without the export’s ETag. With nothing published, and for HEAD, the page is served as exported.

Some paths belong to the worker on every host: anything under /api/, /r/, /.well-known/ and /_/, plus exactly /healthz, /readyz, /openapi.json, /robots.txt and /sitemap.xml. /mcp and /dectog.js belong to the worker on the site and dashboard hosts; on an account’s link host they are ordinary slugs. Everything else depends on the host. The port is ignored when matching hosts.

The site host and the dashboard host both serve the dashboard’s static export (its /_next, /media and /fonts files are shared), each owning some of its pages: the site pages (/, /about, /blog, /blog/<slug>, /contact) belong to the site host, the dashboard pages (/login, /dashboard, /invite, /reset-password) to the dashboard host. A page asked of the other host answers 301 to the same path and query string on its own host. When SITE_URL and WEB_URL name the same host, that host serves every page and nothing redirects.

Host Path Result
any /api/… The REST and admin APIs.
any /r/<username>/<slug> The redirect engine (path-mode link URL).
any except APP_HOST /?user=<username>/<slug> The redirect engine (query-mode link URL).
any /.well-known/… The association files above.
any /_/collect The link pages’ beacon (POST): the screen profile and device settings, and which button the visitor took.
any /healthz, /readyz, /robots.txt, /sitemap.xml See this page.
any /openapi.json The OpenAPI description of every route.
app.dectog.com, dectog.com /mcp The MCP server.
app.dectog.com, dectog.com /dectog.js The web pixel.
dectog.com /, /about, /contact, /SKILL.md, /llms.txt, the export’s files The static export: the landing page, the site pages and the agent docs, so curl https://dectog.com/SKILL.md works without a redirect.
dectog.com /blog, /blog/<slug> The export’s blog pages with the worker’s additions; an unknown slug answers 404 (see Blog post pages).
dectog.com /login, /dashboard…, /invite, /reset-password 301 to WEB_URL plus the path and the query string: https://dectog.com/login?tab=signup goes to https://app.dectog.com/login?tab=signup.
app.dectog.com / 302 to /dashboard with a valid session cookie, else to /login, with cache-control: private, no-store.
app.dectog.com /about, /contact, /blog, /blog/… 301 to SITE_URL plus the path and the query string: https://app.dectog.com/contact?ref=x goes to https://dectog.com/contact?ref=x.
app.dectog.com any other path The dashboard’s static export. /login and /login/ with a valid session cookie answer 302 /dashboard with cache-control: private, no-store.
the apex without an assets binding (local development) any path but / 301 to SITE_URL plus the path and the query string; / answers 302 to SITE_URL.
<username>.dectog.com / Known username: 302 to WEB_URL. Unknown username: 302 to UNKNOWN_HOST_REDIRECT (https://dectog.com/ on the hosted service), or 404 {"error": {"code": "not_found", "message": "Not found"}} when it is unset. www is a reserved username, so www.dectog.com follows the unknown-host rule.
<username>.dectog.com /<slug> The redirect engine. An unknown username here also follows UNKNOWN_HOST_REDIRECT; an unknown slug gets the 404 page and an expired temporary link the 410 page. See How a tap is routed.
<username>.dectog.com two or more path segments 404 {"error": {"code": "not_found", "message": "Not found"}}.

In the hosted deployment WEB_URL and PUBLIC_BASE_URL are both https://app.dectog.com and SITE_URL is https://dectog.com, so account roots send visitors to the dashboard host and unknown hosts to the site.

A HEAD request runs the same handler as GET and the response is returned without a body, with the same status and headers. The redirect engine is the exception: a HEAD to a link URL is treated like a bot and answers the social preview page’s headers without recording a click. Point uptime checks at /healthz or /readyz, not at link URLs.

GET /healthz answers on every host:

{ "ok": true, "service": "dectog", "version": "v1", "host": "app.dectog.com" }

host echoes the request’s Host header (null when absent). The route needs no authentication and is not rate limited.

GET /readyz is the readiness check. It runs SELECT 1 against D1 and compares wrangler’s d1_migrations table with the migrations bundled into this build:

{ "ok": true, "database": true, "migrations": { "expected": 62, "applied": 62, "missing": [] } }

It answers 503 with ok: false when D1 does not answer or a migration is missing, and lists the missing file names. It sends cache-control: no-store. Use it in deploy smoke tests: /healthz passes even with a broken database binding.