diff --git a/README.md b/README.md index 7c660b3..b578b0f 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,20 @@ When a migration fails, the Worker remembers the failure for 30 seconds before a The admin pages open only behind [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/). Access supplies the login and the per-user identity that ownership, API keys and settings run on, and the Worker verifies every Access JWT itself. Enable Access on the `workers.dev` URL in one click, or add a self-hosted application for `/_/admin/*` on your custom domain. Then store the application's AUD tag as the `ACCESS_AUD` secret and your team's key URL as `ACCESS_JWKS_URL`. Until both are set, the admin pages answer a setup page and short links keep redirecting. Step-by-step instructions, the identity table and the full permission model are in [docs/access-control.md](docs/access-control.md). +## Short domains + +A Worker that answers on more than one domain hands out whichever one the operator opened the admin on: the copy button, the QR modal and the QR code all follow the request host. Store `SHORT_ORIGIN` as the origin short links should live on and every one of them uses that instead, whatever host the admin is opened on: + +```bash +npx wrangler secret put SHORT_ORIGIN # c.example, or https://c.example +``` + +Store it as a secret or as a variable in the Cloudflare dashboard, not as a `vars` entry in `wrangler.jsonc`: `yarn types` gives a `vars` value its own literal type, which collides with the optional `SHORT_ORIGIN` that `src/types.ts` declares, and the typecheck stops with TS2430. + +Unset is the default and keeps the request origin. A value that is not a bare http(s) origin is ignored, and the request origin is used. The domain still has to be attached to the Worker: the setting changes which URL is handed out, it does not create the domain. + +A client that builds short URLs itself reads the same origin from `GET /_/api/config`, or from the `short_origin` field every link response carries, rather than assuming the host it reached the API on. See [API](#api). + ## MCP server Point Claude, Copilot or any other MCP client at `https://mcp.` and sign in through Cloudflare Access when the browser opens. The endpoint needs its own subdomain and a second Access application with Managed OAuth turned on, plus the `MCP_ACCESS_AUD` secret. The one setting that catches people out is Cloudflare's "Block AI bots" rule, which has to be off for the zone. The setup walkthrough, client configuration snippets and the tool reference are in [docs/mcp.md](docs/mcp.md). @@ -145,7 +159,7 @@ For full endpoint shapes, parameters, and example payloads, see the live API ref ## SDKs -Shorten URLs, manage slugs and bundles, and read analytics from your own code. All three expose the same resource groups: `links`, `slugs` and `bundles`. +Shorten URLs, manage slugs and bundles, and read analytics from your own code. All three expose the same resource groups: `links`, `slugs`, `bundles` and `config`. - TypeScript/JavaScript: [`@oddbit/shrtnr`](https://www.npmjs.com/package/@oddbit/shrtnr) on npm. Details in [sdk/typescript/README.md](sdk/typescript/README.md). - Python: [`shrtnr`](https://pypi.org/project/shrtnr/) on PyPI. Sync and async clients on httpx. Details in [sdk/python/README.md](sdk/python/README.md). diff --git a/docs/access-control.md b/docs/access-control.md index 967d0cc..8f92308 100644 --- a/docs/access-control.md +++ b/docs/access-control.md @@ -45,6 +45,22 @@ Open `/_/admin/dashboard` and sign in through Access. The Worker validates the J A Worker with a custom domain still answers on its `workers.dev` URL. The Worker's own check keeps the admin pages shut on any host whose Access application it does not know. `wrangler.jsonc` ships with `"preview_urls": false`, so per-version preview URLs stay off; set it to `true` in your fork to preview branch builds. The repository leaves `workers_dev` unset, because a fresh one-click deploy has no other URL, and wrangler then turns `workers.dev` back on at every deploy even after you switch it off in the dashboard. Once a custom domain serves the Worker, either enable Access on `workers.dev` and add its AUD tag to `ACCESS_AUD`, or add `"workers_dev": false` to `wrangler.jsonc` in your fork. +### Which domain the links are handed out on + +A deployment that answers on several domains hands out whichever one the operator opened the admin on: the copy button, the QR modal and the QR code all follow the request host. Set `SHORT_ORIGIN` to pin one instead: + +```bash +npx wrangler secret put SHORT_ORIGIN # c.example, or https://c.example +``` + +Store it as a secret or as a variable in the dashboard, not as a `vars` entry in `wrangler.jsonc`. `yarn types` writes a `vars` value into `worker-configuration.d.ts` as its own literal type, which collides with the optional `SHORT_ORIGIN` that `src/types.ts` merges into `Cloudflare.Env`, and the typecheck stops with TS2430. A binding that arrives as something other than a string counts as unset: the Worker logs one warning and keeps the request origin. + +It takes a bare host or a full origin, and must not carry a path, a query string or credentials: short links live at the root, where the `/:slug` route is. Unset is the default and keeps the request origin, so a deployment that never sets it behaves as before. A value that is not a bare http(s) origin is ignored, the request origin is used instead, and the Worker logs a warning once per isolate. + +A client that composes short URLs itself, an extension or an SDK, reads the resolved origin from `GET /_/api/config` or from the `short_origin` field on any link response. Both report the pinned value when there is one and the request origin otherwise, so a client never has to guess which of several domains is the one links live on. + +The domain still has to be attached to the Worker and reachable: this setting changes which URL is handed out, it does not create the domain. Admin access is a separate question, so an operator can open the admin on one domain and hand out links on another. The identity behind every write still comes from whichever Access application covers the host the admin was opened on. + ### Local development `wrangler dev` runs without Access. `DEV_MODE=true` in `.dev.vars` tells the Worker it runs on a developer machine, and only then does it take the identity from the `dev_identity` cookie or `DEV_IDENTITY`. A deploy never uploads `.dev.vars`, and a configured `ACCESS_AUD` always wins over `DEV_MODE`. See [Local sign-in](../README.md#local-sign-in). diff --git a/e2e/env.ts b/e2e/env.ts index 7954136..ec9cc0e 100644 --- a/e2e/env.ts +++ b/e2e/env.ts @@ -9,6 +9,13 @@ export const BASE_URL = `http://localhost:${PORT}`; /** The identity every spec acts as. Owner-gated writes check against it. */ export const IDENTITY = "e2e@example.com"; +/** + * The origin the e2e server pins as SHORT_ORIGIN. Deliberately not the admin + * host (BASE_URL): the suite has to see the copy button and the QR modal use + * a configured origin rather than the host the page was opened on. + */ +export const SHORT_ORIGIN = "https://c.example"; + /** Written by the setup project, read by every spec. Lives with the server's own throwaway state. */ const STATE_DIR = path.resolve(process.cwd(), ".wrangler/e2e-state"); export const AUTH_STATE = path.join(STATE_DIR, "auth.json"); diff --git a/e2e/short-origin.spec.ts b/e2e/short-origin.spec.ts new file mode 100644 index 0000000..34411a9 --- /dev/null +++ b/e2e/short-origin.spec.ts @@ -0,0 +1,73 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect, type Page } from "@playwright/test"; +import { loadSeed, watchErrors } from "./helpers"; +import { BASE_URL, SHORT_ORIGIN, type Seed } from "./env"; + +/** + * A deployment can answer on several domains. With SHORT_ORIGIN set, the + * admin hands out that one origin no matter which host the operator opened it + * on: the copy button, the toast it raises and the QR modal all name it + * instead of the host in the address bar. The e2e server pins a value that is + * deliberately not BASE_URL, so a build that ignored the setting fails here + * rather than passing by coincidence. + */ +let seed: Seed; +test.beforeAll(() => { + seed = loadSeed(); +}); + +const toast = (page: Page) => page.locator("#toast"); + +// Writing to the clipboard and reading it back both need the permission +// granted; without it the client's writeText() rejects and the page logs an +// error that assertClean() would flag. +test.beforeEach(async ({ context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: BASE_URL }); +}); + +test.describe("pinned short origin", () => { + test("the shell carries it and the copy button builds the URL from it", async ({ page }) => { + const errors = watchErrors(page); + await page.goto("/_/admin/links"); + + await expect(page.locator("html")).toHaveAttribute("data-short-origin", SHORT_ORIGIN); + + const chip = page.locator(".col-short-chip").first(); + const slug = await chip.getAttribute("data-copy-slug"); + expect(slug).toBeTruthy(); + + await chip.click(); + await expect(toast(page)).toHaveText(`Copied ${SHORT_ORIGIN}/${slug}`); + // The toast is raised synchronously, right after writeText() is called, so + // it proves the call was made, not that the write landed. Poll the + // clipboard rather than reading it once: a slow clipboard IPC in headless + // Chromium answers with the previous contents, which are empty on a fresh + // context. + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(`${SHORT_ORIGIN}/${slug}`); + + errors.assertClean(); + }); + + test("the QR modal names it rather than the host in the address bar", async ({ page }) => { + const errors = watchErrors(page); + await page.goto(`/_/admin/links/${seed.newest.id}`); + + // Read the slug the page itself is showing, so the two assertions cannot + // drift apart over which slug is primary. + const displaySlug = await page.locator(".short-url-row button[data-copy-slug]").getAttribute("data-copy-slug"); + expect(displaySlug).toBeTruthy(); + + await page.locator('.short-url-row button[onclick^="showQRModal"]').click(); + await expect(page.locator("#modal p").first()).toHaveText(`${SHORT_ORIGIN}/${displaySlug}`); + + // The image URL carries the origin as a cache key, so a browser that cached + // the SVG before the setting changed cannot keep serving the old QR while + // the text above names the new origin (src/client.ts qrSrc). + const cacheKey = encodeURIComponent(SHORT_ORIGIN); + await expect(page.locator("#qr-img")).toHaveAttribute("src", new RegExp(`[?&]origin=${cacheKey}`)); + + errors.assertClean(); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 4eca12f..03abf1d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { defineConfig, devices } from "@playwright/test"; -import { AUTH_STATE, BASE_URL, PORT } from "./e2e/env"; +import { AUTH_STATE, BASE_URL, PORT, SHORT_ORIGIN } from "./e2e/env"; /** * Browser e2e suite: the app as a user meets it, driven through a real @@ -31,7 +31,7 @@ export default defineConfig({ // Locally a running server (from a previous `yarn e2e`) is reused. CI // always boots its own so the seed lands on a fresh database. reuseExistingServer: !process.env.CI, - env: { E2E_PORT: String(PORT) }, + env: { E2E_PORT: String(PORT), E2E_SHORT_ORIGIN: SHORT_ORIGIN }, }, projects: [ { name: "setup", testMatch: /setup\.ts$/ }, diff --git a/scripts/e2e-server.sh b/scripts/e2e-server.sh index a1af1cf..60555a9 100755 --- a/scripts/e2e-server.sh +++ b/scripts/e2e-server.sh @@ -13,10 +13,15 @@ # through /_/dev/login, not from .dev.vars. CI has no .dev.vars, so the # server gets DEV_MODE on its command line: without it /_/dev/login answers # 404 and the admin pages answer the access setup page. +# +# SHORT_ORIGIN is pinned to something other than the admin host so the suite +# can tell a configured origin apart from the host a page was opened on. Its +# value lives in e2e/env.ts and reaches here through playwright.config.ts. set -euo pipefail PORT="${E2E_PORT:-8797}" +SHORT_ORIGIN="${E2E_SHORT_ORIGIN:-}" STATE=".wrangler/e2e-state" rm -rf "$STATE" @@ -26,4 +31,9 @@ npx --no-install wrangler d1 migrations apply DB --local --persist-to "$STATE" > # exec so the PID Playwright tracks is wrangler itself and its shutdown # signal reaches the server, not a shell wrapper. -exec npx --no-install wrangler dev --port "$PORT" --persist-to "$STATE" --var DEV_MODE:true +VARS=(--var "DEV_MODE:true") +if [ -n "$SHORT_ORIGIN" ]; then + VARS+=(--var "SHORT_ORIGIN:$SHORT_ORIGIN") +fi + +exec npx --no-install wrangler dev --port "$PORT" --persist-to "$STATE" "${VARS[@]}" diff --git a/sdk/dart/README.md b/sdk/dart/README.md index aeeb7f3..0e03e18 100644 --- a/sdk/dart/README.md +++ b/sdk/dart/README.md @@ -121,6 +121,20 @@ final stats = await client.bundles.analytics(bundle.id, range: TimelineRange.las print(stats.totalClicks); ``` +### Config (`client.config`) + +Deployment settings, including the origin short URLs are handed out on. + +| Method | Description | +|---|---| +| `get()` | Get the origin short URLs are handed out on | + +```dart +// Build a short URL that matches the copy button and QR code +final config = await client.config.get(); +print('${config.shortOrigin}/${link.slugs.first.slug}'); +``` + ## Models All model fields use camelCase. The SDK maps snake_case JSON from the wire automatically @@ -128,7 +142,7 @@ inside each `fromJson` factory. Key types exported from `package:shrtnr/shrtnr.dart`: -- `Link`, `Slug`, `Bundle`, `BundleWithSummary`, `BundleTopLink` +- `Link`, `Slug`, `Bundle`, `BundleWithSummary`, `BundleTopLink`, `Config` - `ClickStats`, `TimelineData`, `TimelineBucket`, `TimelineSummary`, `NameCount` - `DateCount`, `SlugCount`, `BreakdownPage` - `DeletedResult`, `AddedResult`, `RemovedResult` @@ -137,6 +151,11 @@ Key types exported from `package:shrtnr/shrtnr.dart`: Timestamp fields (`createdAt`, `expiresAt`, `disabledAt`, `archivedAt`, `updatedAt`) are plain `int` Unix seconds, matching the wire format exactly. +`Link.shortOrigin` and `Config.shortOrigin` give the origin this deployment hands short +URLs out on: the deployment's `SHORT_ORIGIN` when set, otherwise the origin of the request. +Build short URLs as `${shortOrigin}/${slug}` so they match the copy button, the QR code and +the redirects. + ## Errors Every 4xx/5xx response throws `ShrtnrError`. Network failures also throw `ShrtnrError` with diff --git a/sdk/dart/lib/shrtnr.dart b/sdk/dart/lib/shrtnr.dart index a73d80b..4076509 100644 --- a/sdk/dart/lib/shrtnr.dart +++ b/sdk/dart/lib/shrtnr.dart @@ -17,7 +17,7 @@ /// ``` library; -export 'src/client.dart' show ShrtnrClient, LinksResource, SlugsResource, BundlesResource; +export 'src/client.dart' show ShrtnrClient, LinksResource, SlugsResource, BundlesResource, ConfigResource; export 'src/errors.dart' show ShrtnrError; export 'src/models.dart' show @@ -30,6 +30,7 @@ export 'src/models.dart' BundleTopLink, BundleWithSummary, ClickStats, + Config, DateCount, DeletedResult, Link, diff --git a/sdk/dart/lib/src/client.dart b/sdk/dart/lib/src/client.dart index 43d8143..4af2351 100644 --- a/sdk/dart/lib/src/client.dart +++ b/sdk/dart/lib/src/client.dart @@ -5,10 +5,12 @@ import 'package:http/http.dart' as http; import 'base_client.dart'; import 'resources/bundles.dart'; +import 'resources/config.dart'; import 'resources/links.dart'; import 'resources/slugs.dart'; export 'resources/bundles.dart' show BundlesResource; +export 'resources/config.dart' show ConfigResource; export 'resources/links.dart' show LinksResource; export 'resources/slugs.dart' show SlugsResource; @@ -46,6 +48,7 @@ class ShrtnrClient { links = LinksResource(_base); slugs = SlugsResource(_base); bundles = BundlesResource(_base); + config = ConfigResource(_base); } final ShrtnrBaseClient _base; @@ -59,6 +62,9 @@ class ShrtnrClient { /// Bundle management and analytics methods. late final BundlesResource bundles; + /// Deployment settings, including the origin short URLs are handed out on. + late final ConfigResource config; + /// Closes the underlying HTTP client. /// /// Safe to call multiple times. Has no effect when an external [httpClient] diff --git a/sdk/dart/lib/src/models.dart b/sdk/dart/lib/src/models.dart index 8b6a1e3..25fa01a 100644 --- a/sdk/dart/lib/src/models.dart +++ b/sdk/dart/lib/src/models.dart @@ -228,6 +228,7 @@ class Link { required this.createdBy, required this.slugs, required this.totalClicks, + required this.shortOrigin, this.deltaPct, }); @@ -244,6 +245,7 @@ class Link { .map((dynamic s) => Slug.fromJson(s as Map)) .toList(growable: false), totalClicks: ((json['total_clicks'] as num?) ?? 0).toInt(), + shortOrigin: json['short_origin'] as String, deltaPct: (json['delta_pct'] as num?)?.toDouble(), ); @@ -274,6 +276,12 @@ class Link { /// Total clicks across every slug on this link in the requested range. final int totalClicks; + /// Origin this deployment hands the link's short URLs out on: the + /// deployment's `SHORT_ORIGIN` when set, otherwise the origin of the + /// request. Build short URLs as `${shortOrigin}/${slug}` instead of + /// assuming the host the API was reached on. + final String shortOrigin; + /// Click count change as a percentage versus the previous equivalent period. /// Absent when comparison data is unavailable. final double? deltaPct; @@ -292,6 +300,7 @@ class Link { String? createdBy, List? slugs, int? totalClicks, + String? shortOrigin, Object? deltaPct = _unset, }) { return Link( @@ -304,11 +313,30 @@ class Link { createdBy: createdBy ?? this.createdBy, slugs: slugs ?? this.slugs, totalClicks: totalClicks ?? this.totalClicks, + shortOrigin: shortOrigin ?? this.shortOrigin, deltaPct: identical(deltaPct, _unset) ? this.deltaPct : deltaPct as double?, ); } } +/// Deployment settings a client needs to build short URLs, so a client that +/// composes them itself agrees with the copy button, the QR code and the +/// redirects. +@immutable +class Config { + /// Creates a config. Most callers should use [Config.fromJson]. + const Config({required this.shortOrigin}); + + /// Parses a config from its JSON representation. + factory Config.fromJson(Map json) => + Config(shortOrigin: json['short_origin'] as String); + + /// Origin short URLs are handed out on: the deployment's `SHORT_ORIGIN` + /// when set, otherwise the origin of the request. Absolute, with no + /// trailing slash. + final String shortOrigin; +} + /// A collection of links grouped to show combined engagement. @immutable class Bundle { diff --git a/sdk/dart/lib/src/resources/config.dart b/sdk/dart/lib/src/resources/config.dart new file mode 100644 index 0000000..8d946b9 --- /dev/null +++ b/sdk/dart/lib/src/resources/config.dart @@ -0,0 +1,23 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import '../base_client.dart'; +import '../models.dart'; + +/// Methods for the `/api/config` endpoint. +class ConfigResource { + /// Creates a config resource backed by [_http]. + ConfigResource(this._http); + + final ShrtnrBaseClient _http; + + /// Get the origin this deployment hands short URLs out on. + /// + /// On a deployment that answers on several domains, build short URLs as + /// `${shortOrigin}/${slug}` so they match the copy button, the QR code and + /// the redirects. Link responses carry the same value per link. + Future get() async { + final json = await _http.requestJson('GET', '/_/api/config'); + return Config.fromJson(json! as Map); + } +} diff --git a/sdk/dart/pubspec.yaml b/sdk/dart/pubspec.yaml index 713dea1..10a396c 100644 --- a/sdk/dart/pubspec.yaml +++ b/sdk/dart/pubspec.yaml @@ -4,7 +4,7 @@ version: 2.2.1 homepage: https://oddb.it/shrtnr-website-pub repository: https://github.com/oddbit/shrtnr issue_tracker: https://github.com/oddbit/shrtnr/issues -# x-spec-hash: sha256:41888f3ba97eb801c3a5722f190ff0630149f0f8112ef1cfd02191f8c418b02a +# x-spec-hash: sha256:d7929482883835cfdcb4497a169388a32ea11d9e5ce34a200c841bfbc214e526 # Stored as a comment rather than a top-level key to avoid a pana score deduction for unknown manifest fields. topics: diff --git a/sdk/dart/test/client_test.dart b/sdk/dart/test/client_test.dart index fab2daa..66b0fc5 100644 --- a/sdk/dart/test/client_test.dart +++ b/sdk/dart/test/client_test.dart @@ -76,6 +76,7 @@ Map _linkJson({ int totalClicks = 0, double? deltaPct, List> slugs = const [], + String shortOrigin = 'https://c.example', }) => { 'id': id, @@ -87,6 +88,7 @@ Map _linkJson({ 'created_by': 'owner@example.com', 'slugs': slugs, 'total_clicks': totalClicks, + 'short_origin': shortOrigin, if (deltaPct != null) 'delta_pct': deltaPct, }; @@ -459,6 +461,15 @@ void main() { await m.client.links.get(1); expect(m.capture.request!.url.toString(), '$_base/_/api/links/1'); }); + + test("exposes the response's short_origin as shortOrigin", () async { + final m = _mock( + status: 200, + body: _linkJson(shortOrigin: 'https://c.example'), + ); + final link = await m.client.links.get(3); + expect(link.shortOrigin, 'https://c.example'); + }); }); // ---- 4. links.list ---- @@ -1212,7 +1223,34 @@ void main() { }); }); - // ---- 30. Base URL normalization ---- + // ---- 30. config.get ---- + + group('config.get', () { + test('GETs /_/api/config and returns shortOrigin', () async { + final m = _mock( + status: 200, + body: {'short_origin': 'https://c.example'}, + ); + final config = await m.client.config.get(); + expect(config.shortOrigin, 'https://c.example'); + expect(m.capture.request!.url.toString(), '$_base/_/api/config'); + expect(m.capture.request!.method, 'GET'); + }); + + test('sends the Authorization header', () async { + final m = _mock( + status: 200, + body: {'short_origin': 'https://c.example'}, + ); + await m.client.config.get(); + expect( + m.capture.request!.headers['Authorization'], + 'Bearer $_apiKey', + ); + }); + }); + + // ---- 31. Base URL normalization ---- group('Base URL normalization', () { test('strips trailing slashes from baseUrl', () async { @@ -1229,7 +1267,7 @@ void main() { }); }); - // ---- 31. snake_case → camelCase model mapping ---- + // ---- 32. snake_case → camelCase model mapping ---- group('snake_case to camelCase model mapping', () { test('Link maps snake_case fields to camelCase', () async { @@ -1289,7 +1327,7 @@ void main() { }); }); - // ---- 32. BundleAccent enum ---- + // ---- 33. BundleAccent enum ---- group('BundleAccent enum', () { test('wireValue round-trips for every member', () { @@ -1326,7 +1364,7 @@ void main() { }); }); - // ---- 33. TimelineRange enum ---- + // ---- 34. TimelineRange enum ---- group('TimelineRange enum', () { test('wireValue round-trips for every member', () { @@ -1351,7 +1389,7 @@ void main() { }); }); - // ---- 34. BundleArchivedFilter enum ---- + // ---- 35. BundleArchivedFilter enum ---- group('BundleArchivedFilter enum', () { test('wireValue maps to correct strings', () { @@ -1374,7 +1412,7 @@ void main() { }); }); - // ---- 35. Bundle.fromJson null accent ---- + // ---- 36. Bundle.fromJson null accent ---- group('Bundle.fromJson null accent', () { test('defaults to orange when accent is absent', () { @@ -1412,7 +1450,7 @@ void main() { }); }); - // ---- 36. BundleWithSummary.fromJson null accent ---- + // ---- 37. BundleWithSummary.fromJson null accent ---- group('BundleWithSummary.fromJson null accent', () { test('defaults to orange when accent is absent', () { diff --git a/sdk/dart/test/exports_test.dart b/sdk/dart/test/exports_test.dart index 906ab21..547024e 100644 --- a/sdk/dart/test/exports_test.dart +++ b/sdk/dart/test/exports_test.dart @@ -18,6 +18,7 @@ void main() { expect(BundleWithSummary, isNotNull); expect(BundleTopLink, isNotNull); expect(ClickStats, isNotNull); + expect(Config, isNotNull); expect(NameCount, isNotNull); expect(DateCount, isNotNull); expect(SlugCount, isNotNull); @@ -39,6 +40,7 @@ void main() { expect(client.links, isA()); expect(client.slugs, isA()); expect(client.bundles, isA()); + expect(client.config, isA()); client.close(); }); diff --git a/sdk/python/README.md b/sdk/python/README.md index dd2a6fb..f6eff3e 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -136,13 +136,32 @@ stats = client.bundles.analytics(bundle.id, range="7d") print(stats.total_clicks) ``` +### Config (`client.config`) + +Deployment settings a client needs to build short URLs. + +| Method | Description | +|---|---| +| `get()` | Origin short URLs are handed out on | + +```python +# Build a short URL on the origin the deployment hands links out on +config = client.config.get() +short_url = f"{config.short_origin}/{link.slugs[0].slug}" +``` + ## Models All model fields use snake_case, matching the wire format. Types are frozen dataclasses. +Every `Link` carries `short_origin`: the origin this deployment hands short URLs out on (its +`SHORT_ORIGIN` when set, otherwise the origin of the request). Build short URLs as +`f"{short_origin}/{slug}"` so they match the copy button, the QR code and the redirects. + Key types exported from `shrtnr`: - `Link`, `Slug`, `Bundle`, `BundleWithSummary`, `BundleTopLink` +- `Config` - `ClickStats`, `TimelineData`, `TimelineBucket`, `NameCount`, `TimelineSummary` - `DateCount`, `SlugCount` - `DeletedResult`, `AddedResult`, `RemovedResult` diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index a8f4eaa..e0b1376 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -98,7 +98,7 @@ warn_redundant_casts = true warn_unreachable = true [tool.shrtnr] -spec_hash = "sha256:41888f3ba97eb801c3a5722f190ff0630149f0f8112ef1cfd02191f8c418b02a" +spec_hash = "sha256:d7929482883835cfdcb4497a169388a32ea11d9e5ce34a200c841bfbc214e526" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/sdk/python/src/shrtnr/__init__.py b/sdk/python/src/shrtnr/__init__.py index 49c12a0..f827906 100644 --- a/sdk/python/src/shrtnr/__init__.py +++ b/sdk/python/src/shrtnr/__init__.py @@ -5,7 +5,8 @@ Exposes a synchronous :class:`Shrtnr` and asynchronous :class:`AsyncShrtnr` client. Each provides resource-grouped access via ``client.links``, -``client.slugs``, and ``client.bundles``. See README.md for usage. +``client.slugs``, ``client.bundles``, and ``client.config``. See README.md for +usage. """ from importlib.metadata import PackageNotFoundError @@ -22,6 +23,7 @@ BundleTopLink, BundleWithSummary, ClickStats, + Config, DateCount, DeletedResult, Link, @@ -50,6 +52,7 @@ "BundleTopLink", "BundleWithSummary", "ClickStats", + "Config", "DateCount", "DeletedResult", "Link", diff --git a/sdk/python/src/shrtnr/client.py b/sdk/python/src/shrtnr/client.py index 8164a36..982e202 100644 --- a/sdk/python/src/shrtnr/client.py +++ b/sdk/python/src/shrtnr/client.py @@ -11,6 +11,7 @@ from ._base import DEFAULT_TIMEOUT from .resources.bundles import AsyncBundles, Bundles +from .resources.config import AsyncConfig, Config from .resources.links import AsyncLinks, Links from .resources.slugs import AsyncSlugs, Slugs @@ -47,6 +48,7 @@ def __init__( self.links = Links(self._base_url, self._api_key, self._http) self.slugs = Slugs(self._base_url, self._api_key, self._http) self.bundles = Bundles(self._base_url, self._api_key, self._http) + self.config = Config(self._base_url, self._api_key, self._http) # ---- context manager ---- @@ -98,6 +100,7 @@ def __init__( self.links = AsyncLinks(self._base_url, self._api_key, self._http) self.slugs = AsyncSlugs(self._base_url, self._api_key, self._http) self.bundles = AsyncBundles(self._base_url, self._api_key, self._http) + self.config = AsyncConfig(self._base_url, self._api_key, self._http) # ---- context manager ---- diff --git a/sdk/python/src/shrtnr/models.py b/sdk/python/src/shrtnr/models.py index 4e6a0e7..d8c6049 100644 --- a/sdk/python/src/shrtnr/models.py +++ b/sdk/python/src/shrtnr/models.py @@ -58,6 +58,7 @@ class Link: created_by: str slugs: list[Slug] total_clicks: int + short_origin: str delta_pct: float | None = None @classmethod @@ -74,10 +75,20 @@ def from_dict(cls, data: dict[str, Any]) -> Link: created_by=str(data["created_by"]), slugs=[Slug.from_dict(s) for s in data.get("slugs", [])], total_clicks=int(data.get("total_clicks", 0)), + short_origin=str(data["short_origin"]), delta_pct=float(delta) if delta is not None else None, ) +@dataclass(frozen=True) +class Config: + short_origin: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Config: + return cls(short_origin=str(data["short_origin"])) + + @dataclass(frozen=True) class Bundle: id: int diff --git a/sdk/python/src/shrtnr/resources/config.py b/sdk/python/src/shrtnr/resources/config.py new file mode 100644 index 0000000..9390c4b --- /dev/null +++ b/sdk/python/src/shrtnr/resources/config.py @@ -0,0 +1,42 @@ +# Copyright 2026 Oddbit (https://oddbit.id) +# SPDX-License-Identifier: Apache-2.0 + +"""Config resource: sync and async implementations.""" + +from __future__ import annotations + +# The resource class shares the model's name, so the model is aliased to keep +# the ``get`` return annotation pointing at the dataclass and not the resource. +from .._base import ( + _AsyncResource, + _SyncResource, +) +from ..models import Config as ConfigModel + + +class Config(_SyncResource): + """Synchronous Config resource.""" + + def get(self) -> ConfigModel: + """Get the origin this deployment hands short URLs out on. + + Build short URLs as ``f"{config.short_origin}/{slug}"`` so they match + the copy button, the QR code and the redirects on a deployment that + answers on several domains. + """ + url = self._url("/_/api/config") + return ConfigModel.from_dict(self._request("GET", url, headers=self._headers())) + + +class AsyncConfig(_AsyncResource): + """Asynchronous Config resource.""" + + async def get(self) -> ConfigModel: + """Get the origin this deployment hands short URLs out on. + + Build short URLs as ``f"{config.short_origin}/{slug}"`` so they match + the copy button, the QR code and the redirects on a deployment that + answers on several domains. + """ + url = self._url("/_/api/config") + return ConfigModel.from_dict(await self._request("GET", url, headers=self._headers())) diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index f0b2426..2419674 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -45,6 +45,7 @@ def make_link_dict( created_by: str = "owner@example.com", slugs: list[dict[str, Any]] | None = None, total_clicks: int = 0, + short_origin: str = "https://shrtnr.test", delta_pct: float | None = None, ) -> dict[str, Any]: d: dict[str, Any] = { @@ -57,12 +58,17 @@ def make_link_dict( "created_by": created_by, "slugs": slugs if slugs is not None else [make_slug_dict(link_id=link_id, slug="auto")], "total_clicks": total_clicks, + "short_origin": short_origin, } if delta_pct is not None: d["delta_pct"] = delta_pct return d +def make_config_dict(*, short_origin: str = "https://shrtnr.test") -> dict[str, Any]: + return {"short_origin": short_origin} + + def make_bundle_dict( *, bundle_id: int = 42, diff --git a/sdk/python/tests/test_async_client.py b/sdk/python/tests/test_async_client.py index 5b5f7d2..d6aace3 100644 --- a/sdk/python/tests/test_async_client.py +++ b/sdk/python/tests/test_async_client.py @@ -23,6 +23,7 @@ make_bundle_dict, make_bundle_with_summary_dict, make_click_stats_dict, + make_config_dict, make_link_dict, make_slug_dict, make_timeline_dict, @@ -128,6 +129,18 @@ async def test_async_links_get_with_range(client: AsyncShrtnr) -> None: assert route.called +@respx.mock +async def test_async_links_get_exposes_short_origin(client: AsyncShrtnr) -> None: + """See test_links_get_exposes_short_origin in test_client.py.""" + respx.get(f"{BASE_URL}/_/api/links/3").mock( + return_value=httpx.Response( + 200, json=make_link_dict(link_id=3, short_origin="https://c.example") + ), + ) + link = await client.links.get(3) + assert link.short_origin == "https://c.example" + + @respx.mock async def test_async_links_list(client: AsyncShrtnr) -> None: respx.get(f"{BASE_URL}/_/api/links").mock( @@ -422,6 +435,20 @@ async def test_async_bundles_remove_link(client: AsyncShrtnr) -> None: assert result.removed is True +# ---- config.get ---- + + +@respx.mock +async def test_async_config_get(client: AsyncShrtnr) -> None: + route = respx.get(f"{BASE_URL}/_/api/config").mock( + return_value=httpx.Response(200, json=make_config_dict(short_origin="https://c.example")), + ) + config = await client.config.get() + assert config.short_origin == "https://c.example" + assert route.calls[0].request.method == "GET" + assert route.calls[0].request.headers["Authorization"] == f"Bearer {API_KEY}" + + # ---- Context manager ---- diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 13d3e0f..8de94ec 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -14,7 +14,7 @@ import pytest import respx -from shrtnr import Shrtnr, ShrtnrError +from shrtnr import Config, Shrtnr, ShrtnrError from shrtnr.models import Bundle, BundleWithSummary from .conftest import ( @@ -24,6 +24,7 @@ make_bundle_dict, make_bundle_with_summary_dict, make_click_stats_dict, + make_config_dict, make_link_dict, make_slug_dict, make_timeline_dict, @@ -193,6 +194,35 @@ def test_links_get_no_range_param_when_none(client: Shrtnr) -> None: assert "range" not in str(route.calls[0].request.url) +@respx.mock +def test_links_get_exposes_short_origin(client: Shrtnr) -> None: + """Every link response carries the origin the deployment hands short URLs + out on, so a client can build f"{link.short_origin}/{slug}" itself.""" + respx.get(f"{BASE_URL}/_/api/links/3").mock( + return_value=httpx.Response( + 200, json=make_link_dict(link_id=3, short_origin="https://c.example") + ), + ) + link = client.links.get(3) + assert link.short_origin == "https://c.example" + + +@respx.mock +def test_links_list_exposes_short_origin(client: Shrtnr) -> None: + """One response's links all share the same short_origin.""" + respx.get(f"{BASE_URL}/_/api/links").mock( + return_value=httpx.Response( + 200, + json=[ + make_link_dict(link_id=1, short_origin="https://c.example"), + make_link_dict(link_id=2, short_origin="https://c.example"), + ], + ), + ) + links = client.links.list() + assert [link.short_origin for link in links] == ["https://c.example", "https://c.example"] + + # ---- links.list ---- @@ -716,6 +746,27 @@ def test_bundles_remove_link(client: Shrtnr) -> None: assert route.calls[0].request.method == "DELETE" +# ---- config.get ---- + + +@respx.mock +def test_config_get(client: Shrtnr) -> None: + route = respx.get(f"{BASE_URL}/_/api/config").mock( + return_value=httpx.Response(200, json=make_config_dict(short_origin="https://c.example")), + ) + config = client.config.get() + assert config.short_origin == "https://c.example" + assert route.calls[0].request.method == "GET" + assert route.calls[0].request.headers["Authorization"] == f"Bearer {API_KEY}" + + +def test_config_from_dict_raises_on_missing_short_origin() -> None: + """Config.short_origin is required; a missing key must raise KeyError, + not silently default.""" + with pytest.raises(KeyError): + Config.from_dict({}) + + # ---- Context manager ---- diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 830e066..7284a8c 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -119,13 +119,29 @@ const stats = await client.bundles.analytics(bundle.id, { range: "7d" }); console.log(stats.totalClicks); ``` +### Config (`client.config`) + +Deployment settings a client needs to build short URLs. + +| Method | Description | +|---|---| +| `get()` | Origin short URLs are handed out on | + +```ts +// Build a short URL on the origin the deployment hands links out on +const { shortOrigin } = await client.config.get(); +const shortUrl = `${shortOrigin}/${link.slugs[0].slug}`; +``` + ## Models All model fields use camelCase. The SDK converts snake_case JSON from the wire automatically. +Every `Link` carries `shortOrigin`, the origin this deployment hands its short URLs out on (the deployment's `SHORT_ORIGIN`, or the request origin when unset). `client.config.get()` answers the same value on its own. + Key types exported from `@oddbit/shrtnr`: -- `Link`, `Slug`, `Bundle`, `BundleWithSummary`, `BundleTopLink` +- `Link`, `Slug`, `Bundle`, `BundleWithSummary`, `BundleTopLink`, `Config` - `ClickStats`, `TimelineData`, `TimelineBucket`, `NameCount` - `DateCount`, `SlugCount` - `DeletedResult`, `AddedResult`, `RemovedResult` diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 7db1592..08f2478 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -51,5 +51,5 @@ "click-analytics", "custom-slug" ], - "x-spec-hash": "sha256:41888f3ba97eb801c3a5722f190ff0630149f0f8112ef1cfd02191f8c418b02a" + "x-spec-hash": "sha256:d7929482883835cfdcb4497a169388a32ea11d9e5ce34a200c841bfbc214e526" } diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index ea92d92..423a059 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -5,6 +5,7 @@ import { HttpClient } from "./internal/http"; import { LinksResource } from "./resources/links"; import { SlugsResource } from "./resources/slugs"; import { BundlesResource } from "./resources/bundles"; +import { ConfigResource } from "./resources/config"; export interface ShrtnrClientConfig { /** Base URL of your shrtnr deployment, e.g. "https://s.example.com". */ @@ -19,11 +20,13 @@ export class ShrtnrClient { readonly links: LinksResource; readonly slugs: SlugsResource; readonly bundles: BundlesResource; + readonly config: ConfigResource; constructor(config: ShrtnrClientConfig) { const http = new HttpClient(config); this.links = new LinksResource(http); this.slugs = new SlugsResource(http); this.bundles = new BundlesResource(http); + this.config = new ConfigResource(http); } } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 7de3b86..99a35f4 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -11,6 +11,7 @@ export type { BreakdownPage, Slug, Link, + Config, Bundle, BundleTopLink, BundleWithSummary, diff --git a/sdk/typescript/src/models.ts b/sdk/typescript/src/models.ts index f7f6b8a..cb93ee4 100644 --- a/sdk/typescript/src/models.ts +++ b/sdk/typescript/src/models.ts @@ -32,6 +32,18 @@ export interface Link { slugs: Slug[]; totalClicks: number; deltaPct?: number; + /// Origin this deployment hands the link's short URLs out on: the deployment's + /// SHORT_ORIGIN when set, otherwise the origin of the request. Build short URLs + /// as `${shortOrigin}/${slug}` instead of assuming the host the API was reached on. + shortOrigin: string; +} + +/// Deployment settings a client needs to build short URLs, so a client that +/// composes them itself agrees with the copy button, the QR code and the redirects. +export interface Config { + /// Origin short URLs are handed out on: the deployment's SHORT_ORIGIN when + /// set, otherwise the origin of the request. Absolute, no trailing slash. + shortOrigin: string; } export interface Bundle { diff --git a/sdk/typescript/src/resources/config.ts b/sdk/typescript/src/resources/config.ts new file mode 100644 index 0000000..29ea6f1 --- /dev/null +++ b/sdk/typescript/src/resources/config.ts @@ -0,0 +1,20 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import { HttpClient } from "../internal/http"; +import { Config } from "../models"; + +export class ConfigResource { + constructor(private readonly http: HttpClient) {} + + /** + * Read the origin this deployment hands short URLs out on. + * + * A client that composes `${shortOrigin}/${slug}` itself agrees with the + * copy button, the QR code and the redirects on a deployment that answers + * on several domains. Link responses carry the same value per link. + */ + get(): Promise { + return this.http.request("GET", "/_/api/config"); + } +} diff --git a/sdk/typescript/tests/client.test.ts b/sdk/typescript/tests/client.test.ts index 28dce50..177c3f4 100644 --- a/sdk/typescript/tests/client.test.ts +++ b/sdk/typescript/tests/client.test.ts @@ -482,6 +482,7 @@ const stubLink = { id: 1, url: "https://example.com", label: null, created_at: 1000, expires_at: null, created_via: null, created_by: "u", total_clicks: 0, slugs: [], + short_origin: "https://c.example", }; describe("links.get", () => { @@ -503,6 +504,12 @@ describe("links.get", () => { await client().links.get(3); expect(lastCall().url).toBe(`${BASE}/_/api/links/3`); }); + + it("exposes the response's short_origin as shortOrigin", async () => { + mockFetch(200, { ...stubLink, short_origin: "https://c.example" }); + const link = await client().links.get(3); + expect(link.shortOrigin).toBe("https://c.example"); + }); }); describe("links.list", () => { @@ -975,7 +982,29 @@ describe("bundles.removeLink", () => { }); // ============================================================ -// 7. Base URL normalization +// 7. ConfigResource +// ============================================================ + +describe("config.get", () => { + it("GETs /api/config and returns shortOrigin", async () => { + mockFetch(200, { short_origin: "https://c.example" }); + const config = await client().config.get(); + expect(config.shortOrigin).toBe("https://c.example"); + const { url, init } = lastCall(); + expect(url).toBe(`${BASE}/_/api/config`); + expect(init.method).toBe("GET"); + }); + + it("sends the Authorization header", async () => { + mockFetch(200, { short_origin: "https://c.example" }); + await client().config.get(); + const { init } = lastCall(); + expect((init.headers as Record)["Authorization"]).toBe(`Bearer ${API_KEY}`); + }); +}); + +// ============================================================ +// 8. Base URL normalization // ============================================================ describe("Base URL normalization", () => { @@ -995,7 +1024,7 @@ describe("Base URL normalization", () => { }); // ============================================================ -// 8. Package surface +// 9. Package surface // ============================================================ describe("Package surface", () => { @@ -1008,6 +1037,7 @@ describe("Package surface", () => { expect(typeof c.links).toBe("object"); expect(typeof c.slugs).toBe("object"); expect(typeof c.bundles).toBe("object"); + expect(typeof c.config).toBe("object"); }); it("does not publish an internal admin entrypoint in package.json", async () => { diff --git a/src/__tests__/handler/api-config.test.ts b/src/__tests__/handler/api-config.test.ts new file mode 100644 index 0000000..218fba5 --- /dev/null +++ b/src/__tests__/handler/api-config.test.ts @@ -0,0 +1,160 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +// A client that composes a short URL itself has to know which origin the +// deployment hands links out on. On a deployment that answers on several +// domains, the host the client reached the API on is not necessarily the host +// short links live on, so `GET /_/api/config` and the `short_origin` field on +// every link response answer it. Unset keeps the request origin, which is what +// every deployment did before SHORT_ORIGIN existed. +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { env, createExecutionContext } from "cloudflare:test"; +import worker from "../../index"; +import { LinkRepository, BundleRepository } from "../../db"; +import { applyMigrations, resetData } from "../setup"; +import type { Env } from "../../types"; + +const PUBLIC = "https://shrtnr.test"; +const PINNED = "https://c.example"; + +// Seeds an api_keys row directly, the way api-links.test.ts does, so a test can +// mint a key with an arbitrary scope without an admin round-trip. +async function seedApiKey(db: D1Database, scope: string | null, identity = "origin@shrtnr.test"): Promise { + const raw = `sk_${crypto.randomUUID().replace(/-/g, "")}`; + const prefix = raw.slice(0, 7); + const hashBuf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)); + const hash = Array.from(new Uint8Array(hashBuf)) + .map((b) => b.toString(16).padStart(2, "0")).join(""); + await db.prepare( + "INSERT INTO api_keys (identity, title, key_prefix, key_hash, scope, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ).bind(identity, "test", prefix, hash, scope, Math.floor(Date.now() / 1000)).run(); + return raw; +} + +/** The public API against a deployment whose SHORT_ORIGIN is pinned or unset. */ +async function api( + path: string, + options: { key?: string; shortOrigin?: string; method?: string; body?: unknown } = {}, +): Promise { + const e = { ...env, SHORT_ORIGIN: options.shortOrigin } as Env; + return worker.fetch( + new Request(`${PUBLIC}${path}`, { + method: options.method ?? "GET", + headers: { + ...(options.key ? { Authorization: `Bearer ${options.key}` } : {}), + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }), + e, + createExecutionContext(), + ); +} + +beforeAll(applyMigrations); +beforeEach(resetData); + +describe("GET /_/api/config", () => { + it("answers the configured origin", async () => { + const key = await seedApiKey(env.DB, "read"); + + const res = await api("/_/api/config", { key, shortOrigin: PINNED }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ short_origin: PINNED }); + }); + + it("normalizes a bare host the way every other surface does", async () => { + const key = await seedApiKey(env.DB, "read"); + + const res = await api("/_/api/config", { key, shortOrigin: "c.example" }); + + expect(await res.json()).toEqual({ short_origin: PINNED }); + }); + + it("falls back to the origin of the request when the deployment pins nothing", async () => { + const key = await seedApiKey(env.DB, "read"); + + const res = await api("/_/api/config", { key }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ short_origin: PUBLIC }); + }); + + it("ignores a value that is not a bare origin", async () => { + const key = await seedApiKey(env.DB, "read"); + + const res = await api("/_/api/config", { key, shortOrigin: `${PINNED}/s` }); + + expect(await res.json()).toEqual({ short_origin: PUBLIC }); + }); + + it("requires a bearer token", async () => { + const res = await api("/_/api/config"); + + expect(res.status).toBe(401); + const body = (await res.json()) as { error?: string }; + expect(typeof body.error).toBe("string"); + }); + + it("requires the read scope", async () => { + const key = await seedApiKey(env.DB, "create"); + + const res = await api("/_/api/config", { key }); + + expect(res.status).toBe(403); + }); +}); + +describe("link responses carry the origin", () => { + it("on a created link, a fetched link and the list", async () => { + const key = await seedApiKey(env.DB, "read,create"); + + const created = await api("/_/api/links", { + key, + shortOrigin: PINNED, + method: "POST", + body: { url: "https://example.com/origin-create" }, + }); + expect(created.status).toBe(201); + const link = (await created.json()) as { id: number; short_origin?: string }; + expect(link.short_origin).toBe(PINNED); + + const one = await api(`/_/api/links/${link.id}`, { key, shortOrigin: PINNED }); + expect(((await one.json()) as { short_origin?: string }).short_origin).toBe(PINNED); + + const list = await api("/_/api/links", { key, shortOrigin: PINNED }); + const links = (await list.json()) as { short_origin?: string }[]; + expect(links.length).toBeGreaterThan(0); + expect(links.every((item) => item.short_origin === PINNED)).toBe(true); + }); + + it("on a slug lookup, with the request origin when nothing is pinned", async () => { + const key = await seedApiKey(env.DB, "read"); + await LinkRepository.create(env.DB, { url: "https://example.com/origin-slug", slug: "originslug" }); + + const res = await api("/_/api/slugs/originslug", { key }); + + expect(res.status).toBe(200); + expect(((await res.json()) as { short_origin?: string }).short_origin).toBe(PUBLIC); + }); + + it("on the links inside a bundle", async () => { + const key = await seedApiKey(env.DB, "read,create"); + const link = await LinkRepository.create(env.DB, { url: "https://example.com/origin-bundle", slug: "originbundle" }); + const bundle = await BundleRepository.create(env.DB, { name: "Origin bundle", createdBy: "origin@shrtnr.test" }); + + const added = await api(`/_/api/bundles/${bundle.id}/links`, { + key, + shortOrigin: PINNED, + method: "POST", + body: { link_id: link.id }, + }); + expect(added.ok).toBe(true); + + const res = await api(`/_/api/bundles/${bundle.id}/links`, { key, shortOrigin: PINNED }); + const links = (await res.json()) as { short_origin?: string }[]; + expect(links.length).toBe(1); + expect(links[0].short_origin).toBe(PINNED); + }); +}); diff --git a/src/__tests__/handler/mcp.test.ts b/src/__tests__/handler/mcp.test.ts index 93c8026..df0dd96 100644 --- a/src/__tests__/handler/mcp.test.ts +++ b/src/__tests__/handler/mcp.test.ts @@ -15,6 +15,7 @@ import { searchLinks, } from "../../services/link-management"; import { ShrtnrMCP } from "../../mcp/server"; +import type { Env } from "../../types"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; @@ -566,6 +567,76 @@ describe("MCP get_link_qr", () => { }); }); +// ---- get_link_qr and the pinned short origin ---- +// Regression: this tool built its QR from the caller's base_url while the admin +// UI and the REST endpoint (api/qr.ts) built theirs from SHORT_ORIGIN, so one +// deployment handed out two different QR payloads for the same slug depending +// on which surface asked. The agent is built directly rather than reached +// through SELF.fetch, because the test has to pin an environment the way a +// deployment would. + +describe("MCP get_link_qr and SHORT_ORIGIN", () => { + async function callQr( + shortOrigin: unknown, + args: Record, + ): Promise<{ isError?: boolean; text: string }> { + const agent = Object.create(ShrtnrMCP.prototype) as ShrtnrMCP; + Object.assign(agent, { env: { ...env, SHORT_ORIGIN: shortOrigin } as Env }); + agent.server = new McpServer({ name: "shrtnr", version: "test" }); + await agent.init(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test-client", version: "test" }); + await Promise.all([client.connect(clientTransport), agent.server.connect(serverTransport)]); + + try { + const result = (await client.callTool({ name: "get_link_qr", arguments: args })) as { + isError?: boolean; + content: { type: string; text?: string }[]; + }; + return { isError: result.isError, text: result.content.find((c) => c.type === "text")?.text ?? "" }; + } finally { + await client.close(); + } + } + + async function seedQrLink(): Promise<{ id: number; slug: string }> { + const created = await createLink(env as never, { url: "https://example.com/pinned-qr", created_by: "dev@local" }); + if (!created.ok) throw new Error("seed failed"); + return { id: created.data.id, slug: created.data.slugs[0].slug }; + } + + it("prefers the pinned origin over the caller's base_url", async () => { + const { id, slug } = await seedQrLink(); + + const { isError, text } = await callQr("https://c.example", { link_id: id, base_url: "https://a.example" }); + + expect(isError).toBeFalsy(); + expect(text).toContain(`https://c.example/${slug}?utm_medium=qr`); + expect(text).not.toContain("a.example"); + }); + + it("falls back to base_url when the deployment pins nothing", async () => { + const { id, slug } = await seedQrLink(); + + // The trailing slash is trimmed, the same way the admin and REST URL + // builders never produce a double slash. + const { isError, text } = await callQr(undefined, { link_id: id, base_url: "https://a.example/" }); + + expect(isError).toBeFalsy(); + expect(text).toContain(`https://a.example/${slug}?utm_medium=qr`); + }); + + it("asks for base_url when the deployment pins nothing and none is given", async () => { + const { id } = await seedQrLink(); + + const { isError, text } = await callQr(undefined, { link_id: id }); + + expect(isError).toBe(true); + expect(text).toMatch(/base_url/); + }); +}); + describe("MCP error surface", () => { it("malformed JSON-RPC body returns parse error (-32700)", async () => { // No session needed: the parse error fires before session validation. diff --git a/src/__tests__/handler/openapi.test.ts b/src/__tests__/handler/openapi.test.ts index 5442619..6cc4b74 100644 --- a/src/__tests__/handler/openapi.test.ts +++ b/src/__tests__/handler/openapi.test.ts @@ -230,6 +230,7 @@ describe("OpenAPI spec coverage", () => { "/bundles/{id}/analytics", "/bundles/{id}/links", "/bundles/{id}/links/{linkId}", + "/config", ]; const actualKeys = Object.keys(doc.paths).sort(); for (const path of expected) { diff --git a/src/__tests__/handler/qr-origin.test.ts b/src/__tests__/handler/qr-origin.test.ts new file mode 100644 index 0000000..3578f21 --- /dev/null +++ b/src/__tests__/handler/qr-origin.test.ts @@ -0,0 +1,81 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +// The QR endpoint is the server-side half of the same rule the copy button +// follows: with SHORT_ORIGIN set, the encoded URL names the pinned origin +// rather than the host the admin was opened on. The SVG is compared against +// renderQrSvg()'s own output for the expected URL, which pins the payload +// without needing a QR decoder: the renderer is a pure function of the text +// it is given. +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { env, createExecutionContext } from "cloudflare:test"; +import worker from "../../index"; +import { createLink } from "../../services/link-management"; +import { renderQrSvg } from "../../qr"; +import { applyMigrations, resetData } from "../setup"; +import type { Env } from "../../types"; + +beforeAll(applyMigrations); +beforeEach(resetData); + +async function seedLink(): Promise<{ id: number; slug: string }> { + const result = await createLink(env as Env, { url: "https://example.com/landing", created_by: "dev@local" }); + if (!result.ok) throw new Error("seed failed"); + return { id: result.data.id, slug: result.data.slugs[0].slug }; +} + +async function qrSvg(linkId: number, shortOrigin?: string): Promise { + const e = { ...env, SHORT_ORIGIN: shortOrigin } as Env; + const res = await worker.fetch( + new Request(`https://shrtnr.test/_/admin/api/links/${linkId}/qr`), + e, + createExecutionContext(), + ); + expect(res.status).toBe(200); + return res.text(); +} + +/** The SVG the endpoint would serve for this exact short URL. */ +function svgFor(origin: string, slug: string): string | null { + return renderQrSvg(`${origin}/${slug}?utm_medium=qr`, { size: undefined }); +} + +describe("the QR endpoint encodes the pinned short origin", () => { + it("uses SHORT_ORIGIN instead of the request origin", async () => { + const { id, slug } = await seedLink(); + const svg = await qrSvg(id, "https://c.example"); + expect(svg).toBe(svgFor("https://c.example", slug)); + // Guards against a coincidental match: the request origin is a different URL. + expect(svg).not.toBe(svgFor("https://shrtnr.test", slug)); + }); + + it("keeps the request origin when SHORT_ORIGIN is unset", async () => { + const { id, slug } = await seedLink(); + expect(await qrSvg(id)).toBe(svgFor("https://shrtnr.test", slug)); + }); + + it("keeps the request origin when the value is not a bare origin", async () => { + const { id, slug } = await seedLink(); + expect(await qrSvg(id, "https://c.example/s")).toBe(svgFor("https://shrtnr.test", slug)); + }); + + // The admin adds the resolved origin to the QR URL as a browser cache key + // (see qrSrc() in src/client.ts), so the request URL changes when the setting + // does and a cached SVG cannot outlive it. The endpoint must not read that + // parameter: an origin the caller controls would let any client mint a QR for + // a domain this deployment does not serve, and the setting exists precisely + // so the server decides. + it("ignores an origin the caller passes in the query string", async () => { + const { id, slug } = await seedLink(); + const e = { ...env, SHORT_ORIGIN: "https://c.example" } as Env; + const elsewhere = encodeURIComponent("https://elsewhere.example"); + const res = await worker.fetch( + new Request(`https://shrtnr.test/_/admin/api/links/${id}/qr?slug=${slug}&origin=${elsewhere}`), + e, + createExecutionContext(), + ); + + expect(res.status).toBe(200); + expect(await res.text()).toBe(svgFor("https://c.example", slug)); + }); +}); diff --git a/src/__tests__/page/admin-short-origin.test.ts b/src/__tests__/page/admin-short-origin.test.ts new file mode 100644 index 0000000..44d2bea --- /dev/null +++ b/src/__tests__/page/admin-short-origin.test.ts @@ -0,0 +1,75 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +// The admin shell carries the origin short URLs are built from, so the +// browser-side copy button and QR modal can use it without asking the server +// again. Every admin page is listed on purpose: the value travels from +// getPageData() into each route's by hand, and a route that omits the +// required prop fails tsc rather than falling back silently. What the list +// catches is a route that passes the wrong value, such as the request origin +// computed locally instead of the resolved one. +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { env, createExecutionContext } from "cloudflare:test"; +import worker from "../../index"; +import { LinkRepository, BundleRepository } from "../../db"; +import { applyMigrations, resetData } from "../setup"; +import type { Env } from "../../types"; + +beforeAll(applyMigrations); +beforeEach(resetData); + +async function fetchPage(path: string, shortOrigin?: unknown): Promise { + const e = { ...env, SHORT_ORIGIN: shortOrigin } as Env; + const res = await worker.fetch(new Request(`https://shrtnr.test${path}`), e, createExecutionContext()); + expect(res.status, path).toBe(200); + return res.text(); +} + +/** Every admin page that renders through . */ +async function adminPaths(): Promise { + const link = await LinkRepository.create(env.DB, { url: "https://example.com/landing", slug: "abc" }); + const bundle = await BundleRepository.create(env.DB, { name: "Launch", createdBy: "dev@local" }); + return [ + "/_/admin/dashboard", + "/_/admin/links", + `/_/admin/links/${link.id}`, + "/_/admin/bundles", + `/_/admin/bundles/${bundle.id}`, + "/_/admin/keys", + "/_/admin/settings", + ]; +} + +describe("the admin shell carries the short origin", () => { + it("pins the configured origin on every admin page", async () => { + for (const path of await adminPaths()) { + expect(await fetchPage(path, "https://c.example"), path).toContain('data-short-origin="https://c.example"'); + } + }); + + it("falls back to the request origin when SHORT_ORIGIN is unset", async () => { + for (const path of await adminPaths()) { + expect(await fetchPage(path), path).toContain('data-short-origin="https://shrtnr.test"'); + } + }); + + it("renders a bare host as an https origin", async () => { + const html = await fetchPage("/_/admin/links", "c.example"); + expect(html).toContain('data-short-origin="https://c.example"'); + }); + + it("falls back to the request origin when the value is not a bare origin", async () => { + const html = await fetchPage("/_/admin/links", "https://c.example/s"); + expect(html).toContain('data-short-origin="https://shrtnr.test"'); + }); + + // wrangler's `vars` and the dashboard accept JSON values, so the binding can + // reach the Worker as a number. It has to render the page anyway: a cosmetic + // setting that answered every admin route with a 500 would be worse than the + // wrong origin. + it("renders every admin page when the value is not a string", async () => { + for (const path of await adminPaths()) { + expect(await fetchPage(path, 42), path).toContain('data-short-origin="https://shrtnr.test"'); + } + }); +}); diff --git a/src/__tests__/unit/client-short-origin.test.ts b/src/__tests__/unit/client-short-origin.test.ts new file mode 100644 index 0000000..40aa4a8 --- /dev/null +++ b/src/__tests__/unit/client-short-origin.test.ts @@ -0,0 +1,164 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +// The copy button and the QR modal run in the browser, which cannot read the +// Worker's environment, so the pinned origin reaches them as an attribute on +// the admin document. This runs the client script's own shortOrigin() helper +// against a stub document, which is the half of the rule the server cannot +// test: the page tests prove the attribute is rendered, this proves the +// script prefers it and still falls back to the host the admin was opened on. +import { describe, expect, it } from "vitest"; +import { adminClientScript } from "../../client"; +import type { Translations } from "../../i18n/types"; +import { extractTopLevelChunk } from "../client-script"; + +function loadShortOrigin(attribute: string | null, origin: string): () => string { + const script = adminClientScript("1.0.0", {} as unknown as Translations); + const code = [extractTopLevelChunk(script, /^function shortOrigin\(/), "return shortOrigin;"].join("\n"); + const documentElement = { + getAttribute: (name: string) => (name === "data-short-origin" ? attribute : null), + }; + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const factory = new Function("document", "location", code) as (d: unknown, l: unknown) => () => string; + return factory({ documentElement }, { origin }); +} + +describe("client.ts shortOrigin()", () => { + it("prefers the origin the server pinned", () => { + expect(loadShortOrigin("https://c.example", "http://localhost:8797")()).toBe("https://c.example"); + }); + + it("falls back to the host the admin was opened on", () => { + expect(loadShortOrigin(null, "http://localhost:8797")()).toBe("http://localhost:8797"); + }); + + it("treats an empty attribute as unset", () => { + expect(loadShortOrigin("", "http://localhost:8797")()).toBe("http://localhost:8797"); + }); +}); + +// ---- copyUrl ---- +// navigator.clipboard is undefined outside a secure context, which is the +// plain-http LAN deployment SHORT_ORIGIN supports. copyUrl() keeps going after +// the write (quickShorten() redirects on the next line, and the toast names the +// URL for a user who has to copy it by hand), so a throw here was a dead +// button: no toast, no redirect. The guard lives in the shared copyText() +// helper, and the rejection is swallowed for the same reason. + +type CopyUrlHarness = { copyUrl: (slug: string) => void; toasts: string[] }; + +function loadCopyUrl(navigatorObject: unknown): CopyUrlHarness { + const script = adminClientScript("1.0.0", {} as unknown as Translations); + const code = [ + extractTopLevelChunk(script, /^function shortOrigin\(/), + extractTopLevelChunk(script, /^function copyText\(/), + extractTopLevelChunk(script, /^function copyUrl\(/), + "return copyUrl;", + ].join("\n"); + + const toasts: string[] = []; + const documentElement = { + getAttribute: (name: string) => (name === "data-short-origin" ? "https://c.example" : null), + }; + const factory = new Function("document", "location", "navigator", "toast", "t", code) as ( + document: unknown, + location: unknown, + navigator: unknown, + toast: (message: string) => void, + t: (key: string, params?: { url?: string }) => string, + ) => (slug: string) => void; + + return { + copyUrl: factory( + { documentElement }, + { origin: "http://localhost:8797" }, + navigatorObject, + (message: string) => { + toasts.push(message); + }, + (_key, params) => `Copied ${params?.url}`, + ), + toasts, + }; +} + +describe("client.ts copyUrl()", () => { + it("copies the pinned origin and the slug", () => { + const written: string[] = []; + const { copyUrl, toasts } = loadCopyUrl({ + clipboard: { + writeText: (text: string) => { + written.push(text); + return Promise.resolve(); + }, + }, + }); + + copyUrl("abc"); + + expect(written).toEqual(["https://c.example/abc"]); + expect(toasts).toEqual(["Copied https://c.example/abc"]); + }); + + it("keeps the toast when the clipboard is missing", () => { + const { copyUrl, toasts } = loadCopyUrl({}); + + expect(() => copyUrl("abc")).not.toThrow(); + // The toast is the only feedback left, and it names the URL to copy. + expect(toasts).toEqual(["Copied https://c.example/abc"]); + }); + + it("keeps the toast when the clipboard rejects", async () => { + const { copyUrl, toasts } = loadCopyUrl({ + clipboard: { writeText: () => Promise.reject(new Error("NotAllowedError")) }, + }); + + copyUrl("abc"); + // An unhandled rejection fails the vitest run, so letting the microtask + // queue drain is the assertion: the helper has to attach a handler. + await Promise.resolve(); + await Promise.resolve(); + + expect(toasts).toEqual(["Copied https://c.example/abc"]); + }); +}); + +// ---- qrSrc ---- +// The QR response is cached for a day under a URL that would otherwise be +// identical before and after an operator repoints SHORT_ORIGIN, which left the +// modal naming the new origin while the image and both downloads still served +// the old one. The origin is part of the request URL so the browser cannot keep +// serving a QR the deployment has stopped handing out. + +function loadQrSrc(attribute: string | null, origin: string): (linkId: number, slug: string) => string { + const script = adminClientScript("1.0.0", {} as unknown as Translations); + const code = [ + extractTopLevelChunk(script, /^function shortOrigin\(/), + extractTopLevelChunk(script, /^function qrSrc\(/), + "return qrSrc;", + ].join("\n"); + const documentElement = { + getAttribute: (name: string) => (name === "data-short-origin" ? attribute : null), + }; + const factory = new Function("document", "location", "API", code) as ( + document: unknown, + location: unknown, + api: string, + ) => (linkId: number, slug: string) => string; + + return factory({ documentElement }, { origin }, "/_/admin/api"); +} + +describe("client.ts qrSrc()", () => { + it("keys the QR request by the pinned origin", () => { + expect(loadQrSrc("https://c.example", "http://localhost:8797")(7, "abc")).toBe( + "/_/admin/api/links/7/qr?slug=abc&origin=https%3A%2F%2Fc.example", + ); + }); + + it("keys by the host the admin was opened on when nothing is pinned", () => { + expect(loadQrSrc(null, "http://localhost:8797")(7, "abc")).toBe( + "/_/admin/api/links/7/qr?slug=abc&origin=http%3A%2F%2Flocalhost%3A8797", + ); + }); +}); diff --git a/src/__tests__/unit/short-origin.test.ts b/src/__tests__/unit/short-origin.test.ts new file mode 100644 index 0000000..6ea7712 --- /dev/null +++ b/src/__tests__/unit/short-origin.test.ts @@ -0,0 +1,150 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +// SHORT_ORIGIN pins the origin every short URL is built from, so a +// deployment that answers on several domains hands out the one the operator +// chose instead of whichever host the admin was opened on. Unset keeps the +// request origin, which is what every deployment did before the variable +// existed. A value that is not a bare http(s) origin is ignored rather than +// trusted: a typo would otherwise mint links that point nowhere. +import { describe, it, expect, vi } from "vitest"; +import { normalizeShortOrigin, resolveShortOrigin, configuredShortOrigin } from "../../short-origin"; +import type { Env } from "../../types"; + +const REQUEST = "https://worker.account.workers.dev/_/admin/links"; +const REQUEST_ORIGIN = "https://worker.account.workers.dev"; + +function envWith(value?: string): Env { + return { SHORT_ORIGIN: value } as Env; +} + +describe("normalizeShortOrigin", () => { + it("accepts a full origin", () => { + expect(normalizeShortOrigin("https://c.example")).toBe("https://c.example"); + }); + + it("accepts a bare host and supplies https", () => { + expect(normalizeShortOrigin("c.example")).toBe("https://c.example"); + }); + + it("keeps an explicit http origin, for a local deployment", () => { + expect(normalizeShortOrigin("http://localhost:8787")).toBe("http://localhost:8787"); + }); + + it("lowercases the host and drops a trailing slash", () => { + expect(normalizeShortOrigin("HTTPS://C.Example/")).toBe("https://c.example"); + }); + + it("drops a default port", () => { + expect(normalizeShortOrigin("https://c.example:443")).toBe("https://c.example"); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeShortOrigin(" https://c.example ")).toBe("https://c.example"); + }); + + it("rejects a value carrying a path", () => { + // Short links live at the root: the catch-all route is /:slug, so a path + // prefix would name an address nothing serves. + expect(normalizeShortOrigin("https://c.example/s")).toBeNull(); + }); + + it("rejects a query string or fragment", () => { + expect(normalizeShortOrigin("https://c.example?x=1")).toBeNull(); + expect(normalizeShortOrigin("https://c.example#x")).toBeNull(); + }); + + it("rejects credentials", () => { + expect(normalizeShortOrigin("https://user:pw@c.example")).toBeNull(); + }); + + it("rejects a non-http scheme", () => { + expect(normalizeShortOrigin("ftp://c.example")).toBeNull(); + }); + + it("rejects an empty or whitespace-only value", () => { + expect(normalizeShortOrigin("")).toBeNull(); + expect(normalizeShortOrigin(" ")).toBeNull(); + }); + + it("rejects a value that is not a URL at all", () => { + expect(normalizeShortOrigin("not a url")).toBeNull(); + }); +}); + +describe("resolveShortOrigin", () => { + it("falls back to the request origin when unset", () => { + expect(resolveShortOrigin(envWith(undefined), REQUEST)).toBe(REQUEST_ORIGIN); + }); + + it("falls back to the request origin when blank", () => { + expect(resolveShortOrigin(envWith(" "), REQUEST)).toBe(REQUEST_ORIGIN); + }); + + it("pins the configured origin", () => { + expect(resolveShortOrigin(envWith("https://c.example"), REQUEST)).toBe("https://c.example"); + }); + + it("normalizes a bare host", () => { + expect(resolveShortOrigin(envWith("c.example"), REQUEST)).toBe("https://c.example"); + }); + + it("takes a URL object as well as a string", () => { + expect(resolveShortOrigin(envWith(undefined), new URL(REQUEST))).toBe(REQUEST_ORIGIN); + }); + + it("ignores a value that is not a bare origin and keeps the request origin", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(resolveShortOrigin(envWith("https://misconfigured.example/s"), REQUEST)).toBe(REQUEST_ORIGIN); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); + + it("warns once per misconfigured value, not once per request", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const env = envWith("https://repeated.example/path"); + resolveShortOrigin(env, REQUEST); + resolveShortOrigin(env, REQUEST); + resolveShortOrigin(env, REQUEST); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + } + }); +}); + +describe("configuredShortOrigin", () => { + it("returns the normalized origin without needing a request", () => { + expect(configuredShortOrigin(envWith("c.example"))).toBe("https://c.example"); + }); + + it("returns null when unset or blank", () => { + expect(configuredShortOrigin(envWith(undefined))).toBeNull(); + expect(configuredShortOrigin(envWith(" "))).toBeNull(); + }); + + // wrangler's `vars` and the dashboard both accept JSON values, so the + // binding can arrive as a number or an object. It has to count as unset + // rather than be trimmed: a TypeError here answered every admin page and + // both QR routes with a 500, the one outcome the fail-soft rule forbids. + // + // This is the only test that counts the warning, because the counter is per + // isolate: once it fires, no later call in this file can fire it again. + it("treats a non-string binding as unset, warning once", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(configuredShortOrigin({ SHORT_ORIGIN: 42 } as unknown as Env)).toBeNull(); + expect(resolveShortOrigin({ SHORT_ORIGIN: { origin: "c.example" } } as unknown as Env, REQUEST)).toBe( + REQUEST_ORIGIN, + ); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toMatch(/must be a string/); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/src/api/bundles.ts b/src/api/bundles.ts index 9d7e980..c7d2d5d 100644 --- a/src/api/bundles.ts +++ b/src/api/bundles.ts @@ -18,10 +18,11 @@ import { } from "../services/bundle-management"; import { resolveClickFilters } from "../services/admin-management"; import { handlePublicBundleBreakdown } from "./analytics"; -import { fromServiceResult, json } from "./response"; +import { fromLinkResult, fromServiceResult, json } from "./response"; import { createRoute, z } from "@hono/zod-openapi"; import { createApiSubApp } from "./sub-app"; import { requireScope } from "./scope"; +import { resolveShortOrigin } from "../short-origin"; import { AddBundleLinkBodySchema, ArchivedQuerySchema, @@ -401,7 +402,7 @@ const listBundleLinksRoute = createRoute({ bundlesApp.openapi(listBundleLinksRoute, async (c) => { const { id } = c.req.valid("param") as { id: number }; - return fromServiceResult(await listBundleLinks(c.env, id, c.var.auth.identity)) as never; + return fromLinkResult(await listBundleLinks(c.env, id, c.var.auth.identity), resolveShortOrigin(c.env, c.req.url)) as never; }, paramHook); // ---- POST /:id/links (add link to bundle) ---- diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..6178b81 --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,47 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import { createRoute } from "@hono/zod-openapi"; +import { createApiSubApp } from "./sub-app"; +import { ConfigSchema, ErrorResponseSchema } from "./schemas"; +import { json } from "./response"; +import { requireScope } from "./scope"; +import { resolveShortOrigin } from "../short-origin"; + +/** + * Deployment settings a client needs in order to build a short URL. + * + * A client that composes `${baseUrl}/${slug}` itself has no other way to learn + * which origin the deployment hands links out on, so on a deployment that + * answers on several domains it builds URLs on whichever host it was + * configured with while the copy button, the QR code and the redirects use + * SHORT_ORIGIN. Link responses carry the same value per link; this endpoint + * answers it once, for a client that needs it before it holds a link. + */ +export const configApp = createApiSubApp(); + +const errorResponses = { + 401: { description: "Missing or invalid bearer token.", content: { "application/json": { schema: ErrorResponseSchema } } }, + 403: { description: "Scope insufficient.", content: { "application/json": { schema: ErrorResponseSchema } } }, + 500: { description: "Server error.", content: { "application/json": { schema: ErrorResponseSchema } } }, +}; + +const getConfigRoute = createRoute({ + method: "get", + path: "/", + tags: ["config"], + summary: "Read the origin short URLs are handed out on", + description: + "The deployment's `SHORT_ORIGIN` when one is set, otherwise the origin of the request. Build short URLs as `${short_origin}/${slug}` so they match the copy button, the QR code and the redirects on a deployment that answers on several domains.", + middleware: [requireScope("read")] as const, + responses: { + 200: { description: "OK.", content: { "application/json": { schema: ConfigSchema } } }, + 401: errorResponses[401], + 403: errorResponses[403], + 500: errorResponses[500], + }, +}); + +configApp.openapi(getConfigRoute, async (c) => { + return json({ short_origin: resolveShortOrigin(c.env, c.req.url) }) as never; +}); diff --git a/src/api/links.ts b/src/api/links.ts index 1108468..d134531 100644 --- a/src/api/links.ts +++ b/src/api/links.ts @@ -23,8 +23,9 @@ import { listBundlesForLink } from "../services/bundle-management"; import { handleLinkQr } from "./qr"; import { handlePublicLinkAnalytics, handlePublicLinkBreakdown, handlePublicLinkTimeline } from "./analytics"; import { fetchPageTitle } from "../title-fetch"; -import { fromServiceResult, json } from "./response"; +import { fromLinkResult, fromServiceResult, json } from "./response"; import { requireScope } from "./scope"; +import { resolveShortOrigin } from "../short-origin"; import { DEFAULT_QR_SIZE, MAX_QR_SIZE, MIN_QR_SIZE } from "../constants"; import type { Env, TimelineRange, WaitUntilContext } from "../types"; import { @@ -120,7 +121,7 @@ linksApp.openapi(createLinkRoute, async (c) => { if (result.ok && result.status === 201 && !body.label) { c.executionCtx.waitUntil(autoLabelLink(c.env.DB, result.data.id, result.data.url, fetchPageTitle)); } - return fromServiceResult(result) as never; + return fromLinkResult(result, resolveShortOrigin(c.env, c.req.url)) as never; }); // ---- GET / (list links) ---- @@ -149,7 +150,7 @@ linksApp.openapi(listLinksRoute, async (c) => { const { owner, range } = c.req.valid("query") as { owner?: string; range?: TimelineRange }; const opts = range ? { range, withDeltaRange: range } : undefined; const result = owner ? await listLinksByOwner(c.env, owner, opts) : await listLinks(c.env, opts); - return fromServiceResult(result) as never; + return fromLinkResult(result, resolveShortOrigin(c.env, c.req.url)) as never; }); // ---- GET /:id (get link) ---- @@ -174,7 +175,7 @@ const getLinkRoute = createRoute({ linksApp.openapi(getLinkRoute, async (c) => { const { id } = c.req.valid("param") as { id: number }; const { range } = c.req.valid("query") as { range?: TimelineRange }; - return fromServiceResult(await getLink(c.env, id, range ? { range } : undefined)) as never; + return fromLinkResult(await getLink(c.env, id, range ? { range } : undefined), resolveShortOrigin(c.env, c.req.url)) as never; }, paramHook); // ---- PUT /:id (update link) ---- @@ -203,7 +204,7 @@ const updateLinkRoute = createRoute({ linksApp.openapi(updateLinkRoute, async (c) => { const { id } = c.req.valid("param") as { id: number }; const body = c.req.valid("json") as { url?: string; label?: string | null; expires_at?: number | null }; - return fromServiceResult(await updateLink(c.env, id, body, c.var.auth.identity)) as never; + return fromLinkResult(await updateLink(c.env, id, body, c.var.auth.identity), resolveShortOrigin(c.env, c.req.url)) as never; }, paramHook); // ---- POST /:id/disable ---- @@ -227,7 +228,7 @@ const disableLinkRoute = createRoute({ linksApp.openapi(disableLinkRoute, async (c) => { const { id } = c.req.valid("param") as { id: number }; - return fromServiceResult(await disableLink(c.env, id, c.var.auth.identity)) as never; + return fromLinkResult(await disableLink(c.env, id, c.var.auth.identity), resolveShortOrigin(c.env, c.req.url)) as never; }, paramHook); // ---- POST /:id/enable ---- @@ -250,7 +251,7 @@ const enableLinkRoute = createRoute({ linksApp.openapi(enableLinkRoute, async (c) => { const { id } = c.req.valid("param") as { id: number }; - return fromServiceResult(await enableLink(c.env, id, c.var.auth.identity)) as never; + return fromLinkResult(await enableLink(c.env, id, c.var.auth.identity), resolveShortOrigin(c.env, c.req.url)) as never; }, paramHook); // ---- DELETE /:id ---- diff --git a/src/api/qr.ts b/src/api/qr.ts index e5c0cca..3038b1f 100644 --- a/src/api/qr.ts +++ b/src/api/qr.ts @@ -6,12 +6,18 @@ import { Env } from "../types"; import { getLink } from "../services/link-management"; import { renderQrSvg } from "../qr"; import { pickPrimarySlug } from "../slugs"; +import { resolveShortOrigin } from "../short-origin"; import { json } from "./response"; export async function handleLinkQr(request: Request, env: Env, linkId: number): Promise { const url = new URL(request.url); const requestedSlug = url.searchParams.get("slug") ?? undefined; const sizeParam = url.searchParams.get("size"); + // `origin` is deliberately read nowhere below. The admin adds it as a browser + // cache key (see qrSrc() in src/client.ts) so that a cached SVG cannot outlive + // a change to SHORT_ORIGIN, and the payload stays the server's decision: a + // parameter the caller controls would mint QRs for an origin this deployment + // does not serve. if (requestedSlug === "") { return json({ error: "slug must not be empty" }, 400); @@ -36,7 +42,7 @@ export async function handleLinkQr(request: Request, env: Env, linkId: number): if (!slug) return json({ error: "Slug not found" }, 404); - const qrUrl = `${url.origin}/${slug.slug}?utm_medium=qr`; + const qrUrl = `${resolveShortOrigin(env, url)}/${slug.slug}?utm_medium=qr`; const svg = renderQrSvg(qrUrl, { size }); if (!svg) return json({ error: "Failed to generate QR code" }, 500); diff --git a/src/api/response.ts b/src/api/response.ts index d720932..17b46e1 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -20,6 +20,24 @@ export function fromServiceResult(result: import("../services/result").Servic return json(result.data, result.status); } +/** + * A link response carrying the origin this deployment hands short URLs out on. + * + * The origin is per request, not per row: it is the deployment's SHORT_ORIGIN + * when one is set, and the request's own origin otherwise. A client that + * composes `${origin}/${slug}` itself has no other way to learn it, and a + * client that assumes the host it reached the API on hands out the wrong URL on + * a deployment that answers on several domains. List endpoints answer with the + * link objects bare, so arrays are annotated item by item. + */ +export function fromLinkResult(result: import("../services/result").ServiceResult, origin: string): Response { + if (!result.ok) return fromServiceResult(result); + const annotate = (value: unknown) => + typeof value === "object" && value !== null ? { ...value, short_origin: origin } : value; + const data = Array.isArray(result.data) ? result.data.map(annotate) : annotate(result.data); + return fromServiceResult({ ...result, data }); +} + export function formatZodError(err: z.ZodError): string { const first = err.issues[0]; if (!first) return "Invalid request"; diff --git a/src/api/router.ts b/src/api/router.ts index 67236b2..3b91d26 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -7,6 +7,7 @@ import { createApiSubApp } from "./sub-app"; import { linksApp } from "./links"; import { slugsApp } from "./slugs"; import { bundlesApp } from "./bundles"; +import { configApp } from "./config"; export const apiRouter = createApiSubApp(); @@ -57,3 +58,4 @@ apiRouter.get("/docs", (_c) => scalarResponse()); apiRouter.route("/links", linksApp); apiRouter.route("/slugs", slugsApp); apiRouter.route("/bundles", bundlesApp); +apiRouter.route("/config", configApp); diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 8f0b80e..aa609b5 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -54,9 +54,24 @@ export const LinkSchema = z slugs: z.array(SlugSchema), total_clicks: z.number().int().nonnegative().openapi({ example: 137 }), delta_pct: z.number().optional().openapi({ description: "Click count change as percentage. Optional; absent when comparison data is unavailable." }), + short_origin: z.string().url().openapi({ + description: "Origin this deployment hands the link's short URLs out on: the deployment's SHORT_ORIGIN when set, otherwise the origin of this request. Build short URLs as `${short_origin}/${slug}` instead of assuming the host the API was reached on.", + example: "https://oddb.it", + }), }) .openapi("Link", { description: "A short link with its slugs, total click count, and metadata." }); +export const ConfigSchema = z + .object({ + short_origin: z.string().url().openapi({ + description: "Origin short URLs are handed out on: the deployment's SHORT_ORIGIN when set, otherwise the origin of this request.", + example: "https://oddb.it", + }), + }) + .openapi("Config", { + description: "Deployment settings a client needs to build short URLs, so a client that composes them itself agrees with the copy button, the QR code and the redirects.", + }); + export const CreateLinkBodySchema = z .object({ url: z.string().url().max(2048), diff --git a/src/api/slugs.ts b/src/api/slugs.ts index 039f05a..fcd3d12 100644 --- a/src/api/slugs.ts +++ b/src/api/slugs.ts @@ -12,9 +12,10 @@ import { removeSlug, getLinkBySlug, } from "../services/link-management"; -import { json, fromServiceResult } from "./response"; +import { json, fromLinkResult, fromServiceResult } from "./response"; import { requireScope } from "./scope"; import { ErrorResponseSchema, LinkSchema, SlugParamSchema, paramHook } from "./schemas"; +import { resolveShortOrigin } from "../short-origin"; // ---- Legacy admin handlers (still consumed by admin routes in src/index.tsx) ---- export async function handleAddCustomSlug( @@ -103,5 +104,5 @@ const getLinkBySlugRoute = createRoute({ slugsApp.openapi(getLinkBySlugRoute, async (c) => { const { slug } = c.req.valid("param") as { slug: string }; - return fromServiceResult(await getLinkBySlug(c.env, slug.toLowerCase())) as never; + return fromLinkResult(await getLinkBySlug(c.env, slug.toLowerCase()), resolveShortOrigin(c.env, c.req.url)) as never; }, paramHook); diff --git a/src/client.ts b/src/client.ts index 77565c2..2654209 100644 --- a/src/client.ts +++ b/src/client.ts @@ -95,18 +95,44 @@ function api(path, opts) { } // ---- Copy ---- +// The origin short URLs are built from. The server pins it on when +// SHORT_ORIGIN is set (src/short-origin.ts); without that the host the admin +// was opened on is the answer. Read per call rather than cached at load: the +// script is one cached asset shared by every deployment, and the value lives +// on the document. +function shortOrigin() { + var pinned = document.documentElement.getAttribute('data-short-origin'); + return pinned || location.origin; +} + +// Whether the text reached the clipboard. navigator.clipboard is undefined +// outside a secure context, which is the plain-http LAN deployment +// SHORT_ORIGIN supports, and writeText rejects when the permission is denied. +// Both become false here rather than a throw: copyUrl() is called for its side +// effect and its caller keeps going afterwards (quickShorten() redirects), so +// an uncaught TypeError would swallow the toast and the redirect. +function copyText(text) { + if (!navigator.clipboard) return Promise.resolve(false); + return navigator.clipboard.writeText(text).then( + function() { return true; }, + function() { return false; } + ); +} + function copyUrl(slug) { - var url = location.origin + '/' + slug; - navigator.clipboard.writeText(url); + var url = shortOrigin() + '/' + slug; + copyText(url); + // The toast names the URL, so a deployment without a clipboard still shows + // the user what to copy by hand. toast(t('client.copied', {url: url})); } function copyCodeBlock(id) { var el = document.getElementById(id); - if (!el || !navigator.clipboard) return; - navigator.clipboard.writeText(el.textContent || '').then(function() { - toast(t('client.codeCopied')); - }, function() {}); + if (!el) return; + copyText(el.textContent || '').then(function(ok) { + if (ok) toast(t('client.codeCopied')); + }); } // ---- Mobile drawer ---- @@ -275,7 +301,7 @@ function showKeyRevealModal(rawKey) { function copyRawKey() { var key = document.getElementById('revealed-key').textContent; - navigator.clipboard.writeText(key); + copyText(key); toast(t('client.apiKeyCopied')); } @@ -522,10 +548,20 @@ function clearDetailExpiry(linkId) { var _qrSlug = ''; var _qrSrc = ''; +// The origin rides along as a cache key, not as an input: the endpoint decides +// what the QR encodes (see src/api/qr.ts). It is here because the response is +// cached for a day under a URL that would otherwise be identical, so an +// operator who repoints SHORT_ORIGIN would get a modal that names the new +// origin while the image, and both download buttons that fetch this URL, still +// hold the old one. +function qrSrc(linkId, slug) { + return API + '/links/' + linkId + '/qr?slug=' + encodeURIComponent(slug) + '&origin=' + encodeURIComponent(shortOrigin()); +} + function showQRModal(linkId, slug) { _qrSlug = slug; - _qrSrc = API + '/links/' + linkId + '/qr?slug=' + encodeURIComponent(slug); - var shortUrl = location.origin + '/' + slug; + _qrSrc = qrSrc(linkId, slug); + var shortUrl = shortOrigin() + '/' + slug; openModal( '' + '

' + esc(shortUrl) + '

' + diff --git a/src/index.tsx b/src/index.tsx index c753779..04e36fd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,6 +24,7 @@ import { Hono } from "hono"; import type { Env, TimelineRange } from "./types"; import { verifyAccessJwt, extractIdentity, isSignedIn, isDevMode, type AccessUser } from "./access"; import { accessNotConfiguredResponse } from "./access-required"; +import { resolveShortOrigin } from "./short-origin"; import { handleDevLogin, handleDevLogout } from "./dev-login"; import { handleRedirect } from "./redirect"; import { unauthorizedResponse, hasScope, forbiddenResponse } from "./auth"; @@ -232,19 +233,23 @@ async function getPageData(c: { env: Env; req: { raw: Request } }, identity: str const filterBots = settings?.filter_bots ?? true; const filterSelfReferrers = settings?.filter_self_referrers ?? true; const t = createTranslateFn(lang); - return { theme, slugLength, lang, defaultRange, filterBots, filterSelfReferrers, t }; + // Every admin page carries this into , so the browser-side copy + // button and QR modal agree with the server-rendered QR code about which + // origin a short URL lives on. + const shortOrigin = resolveShortOrigin(c.env, c.req.raw.url); + return { theme, slugLength, lang, defaultRange, filterBots, filterSelfReferrers, shortOrigin, t }; } // ---- Admin pages ---- app.get("/_/admin/dashboard", async (c) => { const identity = c.var.identity; - const { theme, t, lang, defaultRange } = await getPageData(c, identity); + const { theme, shortOrigin, t, lang, defaultRange } = await getPageData(c, identity); const rangeParam = c.req.query("range"); const validRanges = new Set(["24h", "7d", "30d", "90d", "1y", "all"]); const range = (validRanges.has(rangeParam || "") ? rangeParam : defaultRange) as TimelineRange; return c.html( - + , ); @@ -252,7 +257,7 @@ app.get("/_/admin/dashboard", async (c) => { app.get("/_/admin/links", async (c) => { const identity = c.var.identity; - const { theme, slugLength, t, lang, defaultRange } = await getPageData(c, identity); + const { theme, slugLength, shortOrigin, t, lang, defaultRange } = await getPageData(c, identity); // Trim before anything reads it. The repository treats a query that trims // to nothing as matching nothing (a bare LIKE "%%" would match every row), // so an untrimmed run of spaces empties the window and the empty state @@ -287,7 +292,7 @@ app.get("/_/admin/links", async (c) => { range, }); return c.html( - + { const id = parseInt(c.req.param("id"), 10); if (isNaN(id)) return notFoundResponse(); const identity = c.var.identity; - const { theme, t, lang, defaultRange } = await getPageData(c, identity); + const { theme, shortOrigin, t, lang, defaultRange } = await getPageData(c, identity); const initialRange: TimelineRange = defaultRange; const filters = await resolveClickFilters(c.env, identity); const linkResult = await getLink(c.env, id, { filters, range: initialRange }); @@ -327,7 +332,7 @@ app.get("/_/admin/links/:id", async (c) => { }; const bundles = bundlesResult.ok ? bundlesResult.data : []; return c.html( - + , ); @@ -335,7 +340,7 @@ app.get("/_/admin/links/:id", async (c) => { app.get("/_/admin/bundles", async (c) => { const identity = c.var.identity; - const { theme, t, lang, defaultRange } = await getPageData(c, identity); + const { theme, shortOrigin, t, lang, defaultRange } = await getPageData(c, identity); const filterParam = c.req.query("filter"); const filter = filterParam === "archived" || filterParam === "all" ? filterParam : "active"; const validRanges = new Set(["24h", "7d", "30d", "90d", "1y", "all"]); @@ -349,7 +354,7 @@ app.get("/_/admin/bundles", async (c) => { }); const bundles = listResult.ok ? listResult.data : []; return c.html( - + , ); @@ -359,7 +364,7 @@ app.get("/_/admin/bundles/:id", async (c) => { const id = parseInt(c.req.param("id"), 10); if (isNaN(id)) return notFoundResponse(); const identity = c.var.identity; - const { theme, t, lang, defaultRange } = await getPageData(c, identity); + const { theme, shortOrigin, t, lang, defaultRange } = await getPageData(c, identity); const rangeParam = c.req.query("range"); const validRanges = new Set(["24h", "7d", "30d", "90d", "1y", "all"]); const range = (validRanges.has(rangeParam || "") ? rangeParam : defaultRange) as TimelineRange; @@ -367,7 +372,7 @@ app.get("/_/admin/bundles/:id", async (c) => { const statsResult = await getBundleAnalytics(c.env, id, range, identity, { filters }); if (!statsResult.ok) return notFoundResponse(); return c.html( - + , ); @@ -375,12 +380,12 @@ app.get("/_/admin/bundles/:id", async (c) => { app.get("/_/admin/keys", async (c) => { const identity = c.var.identity; - const { theme, t, lang } = await getPageData(c, identity); + const { theme, shortOrigin, t, lang } = await getPageData(c, identity); const keysResult = await listAllApiKeys(c.env, identity); const keys = keysResult.ok ? keysResult.data : []; const origin = new URL(c.req.url).origin; return c.html( - + , ); @@ -388,11 +393,11 @@ app.get("/_/admin/keys", async (c) => { app.get("/_/admin/settings", async (c) => { const identity = c.var.identity; - const { theme, slugLength, t, lang, defaultRange, filterBots, filterSelfReferrers } = await getPageData(c, identity); + const { theme, slugLength, shortOrigin, t, lang, defaultRange, filterBots, filterSelfReferrers } = await getPageData(c, identity); const mcpConfigured = Boolean(c.env.MCP_ACCESS_AUD && c.env.ACCESS_JWKS_URL); const userEmail = c.var.user?.email ?? null; return c.html( - + , ); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d9835e1..22702bc 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -58,6 +58,7 @@ import { import type { TimelineRange } from "../types"; import { MIN_SLUG_LENGTH, MAX_SLUG_LENGTH, DEFAULT_SLUG_LENGTH, TIMELINE_RANGES } from "../constants"; import { renderQrSvg } from "../qr"; +import { configuredShortOrigin } from "../short-origin"; import { pickPrimarySlug } from "../slugs"; import { BUNDLE_ACCENTS, @@ -471,15 +472,22 @@ export class ShrtnrMCP extends McpAgent, Props> { "get_link_qr", { title: "Link QR code", - description: "Get a QR code SVG for a short link. The QR encodes the short URL with a utm_medium=qr tracking parameter so scans show up as QR traffic in analytics.", + description: "Get a QR code SVG for a short link. The QR encodes the short URL with a utm_medium=qr tracking parameter so scans show up as QR traffic in analytics. The URL is built from the deployment's SHORT_ORIGIN setting when it is set, so the code matches the URL the admin UI and the API hand out.", inputSchema: { link_id: z.number().int().positive().describe("Numeric ID of the link"), slug: z.string().optional().describe("Specific slug to use (defaults to the link's primary slug)"), - base_url: z.string().url().describe("Base URL of the shrtnr instance, e.g. https://oddb.it"), + base_url: z.string().url().optional().describe("Base URL of the shrtnr instance, e.g. https://oddb.it. Used only when the deployment sets no SHORT_ORIGIN."), }, annotations: { title: "Link QR code", ...READ_ONLY }, }, async ({ link_id, slug: requestedSlug, base_url }) => { + // A deployment that pins SHORT_ORIGIN hands out that origin here too. + // Otherwise one deployment encodes two different URLs for the same slug + // depending on whether an assistant or the admin asked for the QR. + // base_url stays the caller's fallback for a deployment that sets none. + const origin = configuredShortOrigin(this.env) ?? (base_url ? base_url.replace(/\/+$/, "") : null); + if (!origin) return fail("base_url is required when the deployment sets no SHORT_ORIGIN"); + const result = await getLink(this.env, link_id); if (!result.ok) return fail(result.error); const link = result.data; @@ -493,7 +501,7 @@ export class ShrtnrMCP extends McpAgent, Props> { // Same tracked URL shape as the REST QR endpoint (api/qr.ts): the // redirect handler maps utm_medium=qr to link_mode "qr". A bare ?qr // parameter would record the scan as a plain link click. - const qrUrl = `${base_url.replace(/\/+$/, "")}/${target.slug}?utm_medium=qr`; + const qrUrl = `${origin}/${target.slug}?utm_medium=qr`; const svg = renderQrSvg(qrUrl, { size: 400 }); if (!svg) return fail("Failed to generate QR code"); diff --git a/src/pages/layout.tsx b/src/pages/layout.tsx index 85ead36..caf06d0 100644 --- a/src/pages/layout.tsx +++ b/src/pages/layout.tsx @@ -10,6 +10,12 @@ type LayoutProps = { active: string; theme?: string; lang?: string; + /** + * Origin the client script builds short URLs from. Rendered onto + * because the script cannot read the Worker's environment + * (src/short-origin.ts). + */ + shortOrigin: string; t: TranslateFn; }; @@ -17,6 +23,7 @@ export const Layout: FC> = ({ active, theme, lang, + shortOrigin, t, children, }) => { @@ -43,7 +50,7 @@ export const Layout: FC> = ({ ]; return ( - + diff --git a/src/short-origin.ts b/src/short-origin.ts new file mode 100644 index 0000000..dc2bc94 --- /dev/null +++ b/src/short-origin.ts @@ -0,0 +1,105 @@ +// Copyright 2026 Oddbit (https://oddbit.id) +// SPDX-License-Identifier: Apache-2.0 + +import type { Env } from "./types"; + +/** + * The origin short URLs are built from. + * + * The copy button, the QR modal and the QR endpoint used to derive it from + * the request, so the URL a user copied followed whichever host they had + * opened the admin on. A deployment that answers on several domains (a + * workers.dev URL, a custom domain, a second brand's domain) then hands out + * whichever one the operator happened to be using. SHORT_ORIGIN pins it. + * + * Unset is the default and keeps the request origin, so a deployment that + * never sets it behaves exactly as before. A value that is not a bare http(s) + * origin is ignored in favor of the request origin: a typo must not mint + * links that point nowhere. + */ + +/** + * Reduce a configured value to an origin, or null when it is not one. + * + * A bare host is how a domain is normally written and a URL needs a scheme, + * so https is supplied when none is given. http stays available for a + * deployment reached over plain http on a local network. + * + * A path is refused rather than trimmed: short links live at the root (the + * catch-all route is /:slug), so a path prefix would name an address nothing + * serves. Credentials, a query string and a fragment have no meaning here. + */ +export function normalizeShortOrigin(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + + let parsed: URL; + try { + parsed = new URL(withScheme); + } catch { + return null; + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + if (parsed.username || parsed.password) return null; + if (parsed.pathname !== "/" || parsed.search || parsed.hash) return null; + + // origin is the normalization: it lowercases the host and drops a default port. + return parsed.origin; +} + +/** + * Every value already resolved in this isolate, keyed by the raw string. + * + * An isolate's environment never changes, so one parse per value is enough for + * every page render and QR request that follows. A null records a value that is + * not a bare origin: caching the failure too is what keeps a repeated typo to + * one warning and keeps `new URL` off the request path. + */ +const resolved = new Map(); + +/** Whether a non-string binding has been reported, so it warns once per isolate. */ +let warnedNonString = false; + +/** + * The origin SHORT_ORIGIN pins, or null when it is unset, blank or unusable. + * + * Anything that is not a string counts as unset. `wrangler`'s `vars` and the + * dashboard accept JSON values, and a cosmetic setting must not answer every + * admin page and both QR routes with a TypeError: the caller falls back to the + * origin it already has, which for a request is the request's own. + */ +export function configuredShortOrigin(env: Env): string | null { + const value: unknown = env.SHORT_ORIGIN; + + if (typeof value !== "string") { + if (value !== undefined && value !== null && !warnedNonString) { + warnedNonString = true; + console.warn(`SHORT_ORIGIN must be a string and is ignored: ${JSON.stringify(value)}`); + } + return null; + } + + const raw = value.trim(); + if (!raw) return null; + + const cached = resolved.get(raw); + if (cached !== undefined) return cached; + + const normalized = normalizeShortOrigin(raw); + if (!normalized) { + console.warn(`SHORT_ORIGIN is not a bare http(s) origin and is ignored: ${JSON.stringify(raw)}`); + } + resolved.set(raw, normalized); + return normalized; +} + +/** + * The origin to build short URLs from for this request. Pass the request URL: + * its origin is both the fallback and the answer when the variable is unset. + */ +export function resolveShortOrigin(env: Env, requestUrl: string | URL): string { + return configuredShortOrigin(env) ?? new URL(requestUrl).origin; +} diff --git a/src/types.ts b/src/types.ts index 9116201..db9f1c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,12 @@ declare global { MCP_ACCESS_AUD: string; // AUD tag from the MCP CF Access application (Managed OAuth) ACCESS_JWKS_URL: string; // https://.cloudflareaccess.com/cdn-cgi/access/certs + // Optional: the origin short URLs are built from, for a deployment that + // answers on several domains. A bare host or a full origin ("c.example", + // "https://c.example"). Unset keeps the origin of the request that + // rendered the page (see src/short-origin.ts). + SHORT_ORIGIN?: string; + // Dev-only: "true" in .dev.vars and the test pools. Lets the Worker take // an unverified identity while ACCESS_AUD is unset (see isDevMode). DEV_MODE?: string;