Skip to content

Short Links

Daniel Truong edited this page Sep 23, 2026 · 11 revisions

Short links

demi-api mints short codes that redirect to a full URL, for print and campaign use where a long projects.eao.gov.bc.ca link does not fit. A code is a Cosmos NoSQL document; resolving it is the only anonymous path in this feature. Routes: src/http/routes.js. Controller: src/controllers/nosql/link.js. Validation: src/helpers/link-url.js. Code generation and the project link: src/helpers/short-links.js.

Public redirect

GET /s/{code} — no authentication.

Condition Status Headers Body
Code exists 302, Location = stored url Cache-Control: no-store none
Code missing or malformed 404 Cache-Control: no-store static HTML page, "Link not found"

The redirect is always 302, never 301: a repointed link must take effect on the next request, and a cached permanent redirect on a printed poster can never be corrected. Every hit logs one line, whether or not the code resolves:

[demi-api] short link <code> hit=<bool>

/api/links routes

Method Path Auth Request Response
GET /api/links read role or above, bearer or API key — array of link objects
POST /api/links write role, bearer or API key url (required), note (optional, ≤200 chars), code (optional vanity alias), personal (optional boolean) 201 { code, shortUrl, url }; 409 if code is taken
PUT /api/links/{code} write role, bearer or API key url (required) 200 updated link object
DELETE /api/links/{code} write role, bearer or API key — 200 { message }

Auth is X-Api-Key (registry key) or a Keycloak bearer token, realm eao-epic. GET needs any privileged role (sysadmin, staff, demi-admin, demi-service-read, demi-service-write); POST/PUT/DELETE need a write role (sysadmin, staff, demi-admin, demi-service-write). A read-only credential gets 403 on a write. Full schema: src/swagger/swagger.yaml, paths /api/links and /api/links/{code}.

A code that belongs to a project (see Project links below) cannot be written through /api/links: POST, PUT and DELETE on that code return 409 and name the project (projectId) when the caller can read it, otherwise a plain 409. Only PUT /api/projects/{id}/short-code moves that code. Rows in GET /api/links for a project's link carry projectId and projectRole, so the admin UI can send an edit to the project route instead of the link route.

A link object:

Field Type Notes
id string the code; also the Cosmos id and partition key
url string destination, as validated when written
note string or null free text, e.g. a campaign name
shortUrl string LINK_BASE_URL + /s/ + id, composed by the API
createdAt string (ISO date)
createdBy string Keycloak username or API key id
updatedAt string (ISO date) or null null until the link is repointed
personal boolean false (shared) unless set true at create time. A row with no personal field predates the flag and counts as shared
projectId string, absent present only when the code is a project's link and the caller can read that project
projectRole current or legacy, absent current if this is the project's live code, legacy if it is an old code the project used to hold. Absent under the same condition as projectId

personal: true hides the link from GET /api/links for every caller except its creator. GET /s/{code} is unaffected — the redirect stays public for anyone holding the URL, regardless of the flag. The Short URLs screen (Frontend) groups the list into My links and Shared links from this same field.

The admin UI panel for creating and managing links is a client of these same routes.

Project links

Every project with a public page carries one short link of its own: LINK_BASE_URL + /s/ + shortCode, redirecting 302 by default to LINK_BASE_URL + /p/ + the project's Eagle id. Only projects with an Eagle id get one: a project without one has no public page to point at.

Code from the project name

The code is derived from the project name: lowercase, accents stripped, & becomes and, apostrophes dropped, every other non-alphanumeric character becomes -, then capped at 40 characters at a word break. "Site C Clean Energy" becomes site-c-clean-energy.

If that code is taken, the job retries with -2 through -5 appended. If all of those are also taken, it falls back to a random 8-character code (same alphabet as Destination rules).

POST /projects does not accept a shortCode: a project created this way has no Eagle id yet, so there is no /p/{eagleId} page for a link to point at.

Setting a code or destination by hand

Staff change a project's code, its destination, or both, once the project has a public page:

PUT /api/projects/{id}/short-code
{ "shortCode": "site-c-eac", "url": "https://example.gov.bc.ca/campaign" }

Both fields are optional but at least one must be sent. Staff role required (requireAdmin: sysadmin, staff, demi-admin) — the service API key is refused. shortCode must match ^[a-z0-9_-]{3,64}$ (400 otherwise) or the project has no Eagle id (400); 409 if the code is already in use by another link. url follows the same destination rules as any link; sending url: null, or a URL equal to the project's own public page, resets the destination to that public page. 503 on a write conflict — the same request is safe to repeat. The API never silently renames a taken code to something else.

The response repeats the current state:

Field Notes
shortCode the live code, or null
shortUrl composed from shortCode, or null
legacyShortCodes codes the project used to hold; each still resolves
url the current destination (the public page, if not repointed)
shortLinkCustom true once staff have repointed the link away from the public page

Staff edit a project's code and destination in the Short links screen of eagle-demi-admin, the admin app and the only staff UI on prod. On test, the same edit is also available on the project page of the DEMI React Frontend. On the React Short links screen, project codes are read-only and link to the project page.

Old codes keep working

Changing a project's code or destination does not delete the old code. It moves into the project's legacyShortCodes list and keeps redirecting to wherever the project's current code points — nothing under /s/ is ever deleted for a project, because a code may already be printed on a notice or poster. Moving the code back to one of its legacy codes reuses that same record. Renaming the project does not change its code.

Sync behaviour for existing projects

Projects that already have one of the older random 8-character codes keep it working; nothing breaks on upgrade. Migration to a name-derived code runs as part of the nightly sync, for every project it touches, Track-listed or Eagle-only. The sync changes only the short-link fields on a project, and skips a row that was changed since the sync read it, rather than overwrite it. On migration, the job mints a name-derived code as described above and moves the random code into legacyShortCodes. If every name-derived candidate through -5 is taken, the project keeps its old random code. A code set by staff is never overwritten by migration.

The same nightly sync also repairs project links left pointing at the wrong host — for example after LINK_BASE_URL changes — up to 500 records per run. That repair pass only repoints existing records; it never mints a code, so it never touches a project staff have not already linked.

Each project stores shortCode, legacyShortCodes, and shortCodeSource (name, random, or staff) recording how the current code was assigned. Link rows themselves are unchanged: each code is still a row in the links container, keyed by the code. PUT /links/{code} and DELETE /links/{code} return 409 for any code a project currently holds or has ever held (in shortCode or legacyShortCodes), so a printed project link can't be edited or deleted through the general links routes. POST /links with a custom code that a project currently holds or has ever held also returns 409.

GET /api/projects/{id} returns shortCode and, composed from it, shortUrl. shortCodeSource is included only for staff callers. The full URL is never stored, only composed per environment, so a project record restored into another environment cannot hand out that environment's host.

Destination rules

Checked once, at create or repoint time, never again at redirect time.

Rule Value
Scheme https: only
Host must equal an entry in LINK_ALLOWED_HOSTS, or end with . + that entry
Credentials user:pass@ in the URL is refused
Length 2048 characters max
note length 200 characters max
Custom code pattern ^[a-z0-9_-]{3,64}$, case-insensitive on lookup

Omit code on create and the API generates one: 8 characters drawn from abcdefghjkmnpqrstuvwxyz23456789 (31 symbols, excludes 0 o 1 l i so a printed code can be retyped without guessing which glyph it is). A generated code that collides retries once; a custom code that collides returns 409 for the caller to change. A custom code is lowercased before it is stored, so Site-C-EAC and site-c-eac are the same link.

Config

Setting Test Prod Purpose
LINK_BASE_URL https://test.projects.eao.gov.bc.ca https://projects.eao.gov.bc.ca host used to compose shortUrl; set per environment in azure/main.<env>.bicepparam
LINK_ALLOWED_HOSTS gov.bc.ca gov.bc.ca comma-separated allowlist of destination host suffixes; same default in both estates

LINK_BASE_URL is an app setting, set per environment in azure/main.<env>.bicepparam and wired into the Flex app by azure/modules/api-function-flex.bicep: a value not declared there is dropped on the next infra deploy, not merely left unchanged. LINK_ALLOWED_HOSTS has no app setting; both environments run on the gov.bc.ca default in src/config.js.

Storage

Cosmos NoSQL container links, partition key /id (the code itself), no TTL. A redirect is a single-partition point read; a code clash on create is a single-partition write. Links are permanent by design — there is no expiry, only a hard DELETE, and deleting one is unrecoverable except through the audit log.

Traffic path

browser → eao-nginx location ^~ /s/ → APIM /api/s/{code} → demi-api GET /s/{code}

eao-nginx proxies /s/ to /api/s/ on API Management, where demi-api is mounted under /api. The /s/ segment stays in the upstream URI. Dropping it would map /s/projects onto demi-api's /projects route and expose the whole API anonymously. Config: conf.d/server.conf.tmpl in eao-nginx, the location ^~ /s/ block.

Querying hits

Hit counts per code, from the Flex app's traces in Log Analytics (App Insights workspace mode):

AppTraces
| where Message contains "short link"
| parse Message with * "short link " code " hit=" hit
| summarize Hits = count(), Resolved = countif(hit == "true") by code

DEMI Wiki

Home

Current system

Azure

Domain

ADRs

History

Related

Clone this wiki locally