Repository navigation
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.
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>
| 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.
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.
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.
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.
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.
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.
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.
| 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.
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.
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.
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- Environment Reality & Gotchas
- Architecture
- Seeding — filling Cosmos from Track and Eagle
- Sources and Status — every source, one state
- Search & Retrieval — overview
- Search Index Reference
- Search Query Construction
- Search Measurements
- Extraction Pipeline
- Extraction Quality — coverage, word-joining, tiling
- Identity Model — which id means what
- Attribute-Level Access — field-visibility levels, catalog
- Sync Architecture — how records get in
- Eagle Mirror Reference — mirrored kinds, refusals, drift classes
- ENGAGE Rows Reference — ENGAGE-owned comment periods, reconcile
- Public-Read Backfill — filling and repairing the mirror containers
-
Short Links —
/s/{code}redirect and/api/links -
Recent Uploads —
/api/documents/recent-uploads, eagle-public homepage panel - Reports — comment export and BCGW CSV reads
-
User Data — saved map areas, preferences,
/meroutes -
Bulk Download —
/api/bulk-downloads, zip jobs - Bulk Download: Operations — enable, poison-queue runbook
- Document Intake — upload link, malware scan, release
- Connecting an Application to DEMI
- Azure Environments
- Document Backup — archive copy of originals, run and check
- Document Backup: Revert and Drill — put documents back from the archive
- PDF Titles — public name written into stored PDFs, run, restore
- Secret Sync — Key Vault, secret-sync Function app, required names
-
Operating DEMI in Azure — deploy, verify
- Operating the DEMI Data Plane — devbox access, probes, audit and cost reads
- Edge ban detector — hourly timer, modes, kill switch, soak and promote
-
CI Workflows — every GitHub Actions workflow, trigger, identity, roles
- CI Deploy Workflows — eagle-demi staging and production deploys
- CI Admin Frontend Workflows — eagle-demi-admin checks and deploys
- Release Process — cutting, tagging, shipping and rolling back a version
- Runbook: Search Outage — index/app schema drift, diagnose, fix, roll back
- BC Gov Landing Zone
- Business Rules
- EPIC.search Business Rules — where DEMI differs from EPIC.search
- 001: Typesense for DFL Search — superseded
- 002: Async Processing (MongoDB Queue) — superseded
- 003: Rule-Based Classification
- 004: Read ACL Authorization Model
- 005: Cosmos Full-Text Search Ruled Out
- 006: AI Summarization over BM25
- 007: Service-to-Service Credentials
- 008: Audit and Usage Analytics Store
- 009: Track Feed — proposed
- 010: Attribute-Level Access Control
- 011: Public Read Surface
- 012: Public Config Surface
- 013: Stored Project Summaries — proposed
- 014: Sync-out to Eagle
- 015: Malware scanning on intake