Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<your-domain>` 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).
Expand All @@ -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).
Expand Down
16 changes: 16 additions & 0 deletions docs/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
7 changes: 7 additions & 0 deletions e2e/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
73 changes: 73 additions & 0 deletions e2e/short-origin.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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$/ },
Expand Down
12 changes: 11 additions & 1 deletion scripts/e2e-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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[@]}"
21 changes: 20 additions & 1 deletion sdk/dart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,28 @@ 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
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`
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion sdk/dart/lib/shrtnr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +30,7 @@ export 'src/models.dart'
BundleTopLink,
BundleWithSummary,
ClickStats,
Config,
DateCount,
DeletedResult,
Link,
Expand Down
6 changes: 6 additions & 0 deletions sdk/dart/lib/src/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -46,6 +48,7 @@ class ShrtnrClient {
links = LinksResource(_base);
slugs = SlugsResource(_base);
bundles = BundlesResource(_base);
config = ConfigResource(_base);
}

final ShrtnrBaseClient _base;
Expand All @@ -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]
Expand Down
28 changes: 28 additions & 0 deletions sdk/dart/lib/src/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ class Link {
required this.createdBy,
required this.slugs,
required this.totalClicks,
required this.shortOrigin,
this.deltaPct,
});

Expand All @@ -244,6 +245,7 @@ class Link {
.map((dynamic s) => Slug.fromJson(s as Map<String, dynamic>))
.toList(growable: false),
totalClicks: ((json['total_clicks'] as num?) ?? 0).toInt(),
shortOrigin: json['short_origin'] as String,
deltaPct: (json['delta_pct'] as num?)?.toDouble(),
);

Expand Down Expand Up @@ -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;
Expand All @@ -292,6 +300,7 @@ class Link {
String? createdBy,
List<Slug>? slugs,
int? totalClicks,
String? shortOrigin,
Object? deltaPct = _unset,
}) {
return Link(
Expand All @@ -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<String, dynamic> 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 {
Expand Down
23 changes: 23 additions & 0 deletions sdk/dart/lib/src/resources/config.dart
Original file line number Diff line number Diff line change
@@ -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<Config> get() async {
final json = await _http.requestJson('GET', '/_/api/config');
return Config.fromJson(json! as Map<String, dynamic>);
}
}
2 changes: 1 addition & 1 deletion sdk/dart/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading