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: 16 additions & 0 deletions .changeset/micropub-q-geo-location-venue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@dwk/micropub": minor
---

Add the proposed Location/Venue (`q=geo`) extension: a read-only proximity
search over an injected, strongly-consistent venue store, independent from
post storage. Disabled by default — requires `extensions.proposed: true` and a
configured `venues` store (`createMicropubVenueStore` for the built-in
D1-backed implementation). Accepts a Geo URI or discrete `lat`/`lon`
coordinates plus an optional `u` radius (default 1,000m, max 50,000m), and
returns venues ordered by great-circle distance with `limit`/`offset`
pagination. `geo`'s location suggestion is currently a placeholder that echoes
the query coordinates back — no reverse-geocoding service is wired in yet.

Implements the design from #359 per
https://indieweb.org/Micropub-extensions#Location/Venue. Tracked by #354.
5 changes: 5 additions & 0 deletions packages/micropub/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ toggled by maturity group via the `extensions` config
newest-first, with `limit`/`offset` pagination (#351/#353).
- **Richer Post List Filters** — proposed-only `q=source` filters with keyset
cursors; see the package spec for the wire contract.
- **Location/Venue** (`q=geo`, #359) — proposed-only read-only proximity
search over an injected `venues` D1 store, independent from post storage.
`geo`'s reverse-geocoded suggestion is a placeholder (echoes the query
coordinates) until a real lookup is wired in.

## Spec

Expand Down Expand Up @@ -75,6 +79,7 @@ src/store.ts # createMicropubStore (D1-backed post persistence)
src/mf2.ts # mf2 body parsing (form + JSON), update operations, source view, list view
src/pagination.ts # offset-based pagination parsing/validation (pure, reusable)
src/source-filters.ts # proposed source-list filter and cursor parsing (pure)
src/venues.ts # proposed q=geo venue store (D1) + query parsing
src/auth.ts # token extraction, scope checking, DPoP enforcement
src/event.ts # h-event post type: markup rendering, h-event → CalendarEvent
src/fediverse.ts # h-entry → PostInput adapter + syndication to @dwk/activitypub's /publish (#278; wire-format contract, no AP import)
Expand Down
37 changes: 29 additions & 8 deletions packages/micropub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,37 @@ The handler fails loudly at startup if any of these are missing:
- **Opt-in source-list filters**: proposed deployments can filter a `q=source`
list by creation bounds, type, status, visibility, or exact mf2 properties;
filtered lists use deterministic keyset cursors.
- **Opt-in Location/Venue** (`q=geo`): a read-only proximity search over an
injected venue store, independent from post storage. See below.

### Proposed Location/Venue design
### Location/Venue (`q=geo`) extension

`q=geo` is a proposed extension and remains disabled by default; it is not yet
implemented. Its API and injected, strongly-consistent `VenueStore` contract
are defined in the [package specification](../../spec/packages/micropub.md#proposed-locationvenue-qgeo).
When implemented, a Geo URI or `lat`/`lon`/`u` query will return a
reverse-geocoded `geo` suggestion and nearby `venues`. Venue lookup is separate
from post storage; a client references a selected venue with the ordinary
`location` post property.
The `q=geo` extension is implemented for the proposed Location/Venue feature.
It remains disabled by default (`extensions.proposed: false`); clients must
enable the `proposed` group and configure a `venues` store to use it.

A `GET ?q=geo&uri=geo:lat,lon;u=radius` or `GET ?q=geo&lat=...&lon=...&u=...` query
Comment thread
davidwkeith marked this conversation as resolved.
returns a `geo` suggestion and nearby venues ordered by distance. **`geo` is not
a real reverse-geocoding lookup** — this first implementation echoes the query
coordinates back as `geo.label`; wiring in an actual place-name service is
future work. Each venue has `name`, `latitude`, `longitude`, and a canonical
`url` (populated by whatever writes venue rows — venue create/update/delete is
out of scope for this read-only query). Clients reference a venue via the
post's `location` property (either plain text or an `h-card` with `url`). The
store is independent of post storage — querying `q=geo` never reads post data.

```ts
import { createMicropub, createMicropubVenueStore } from "@dwk/micropub";

const micropub = createMicropub({
baseUrl: "https://example.com",
me: "https://example.com/",
extensions: { proposed: true },
venues: createMicropubVenueStore(env),
});
```

See the [package specification](../../spec/packages/micropub.md#locationvenue-qgeo-extension).

Every request is authorized by an IndieAuth access token whose scope gates the
action (`create`, `update`, `delete`, `media`), with the DPoP proof-of-possession
Expand Down
10 changes: 10 additions & 0 deletions packages/micropub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
MicropubContactStore,
MicropubContactStoreEnv,
} from "./contacts.js";
import type { MicropubVenueStore } from "./venues.js";
import type { Mf2Object, MicropubCommands } from "./mf2.js";

/**
Expand Down Expand Up @@ -138,6 +139,12 @@ export interface MicropubConfig {
* advertised only when this is set and the proposed group is enabled.
*/
readonly contacts?: MicropubContactStore | MicropubContactStoreProvider;
/**
* Venue store for the proposed Location/Venue (`q=geo`) extension. Venues
* are queried via proximity search and are independent from post storage.
* The store enables `q=geo` when configured and the proposed group is enabled.
*/
readonly venues?: MicropubVenueStore;
/**
* Post types advertised as `post-types` in `q=config` (the stable Supported
* Vocabulary extension). Omitted from the response when unset, or when the
Expand Down Expand Up @@ -210,6 +217,8 @@ export interface ResolvedConfig {
readonly audienceIds: ReadonlySet<string>;
/** Normalized Contacts store provider, when the extension is configured. */
readonly contacts?: MicropubContactStoreProvider;
/** Normalized Venue store provider, when the extension is configured. */
readonly venues?: MicropubVenueStore;
readonly postTypes?: readonly PostTypeConfig[];
/** Normalized to an async provider regardless of the configured shape. */
readonly syndicateTo: () => Promise<readonly SyndicationTarget[]>;
Expand Down Expand Up @@ -344,6 +353,7 @@ export function resolveConfig(config: MicropubConfig): ResolvedConfig {
audiences,
audienceIds,
...(contactStore ? { contacts: contactStore } : {}),
...(config.venues ? { venues: config.venues } : {}),
...(config.postTypes ? { postTypes: config.postTypes } : {}),
syndicateTo: normalizeSyndicateTo(config.syndicateTo),
...(config.fediverse ? { fediverse: config.fediverse } : {}),
Expand Down
53 changes: 53 additions & 0 deletions packages/micropub/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ import {
contactWrite,
type MicropubContactStore,
} from "./contacts.js";
import {
parseVenueSearchParams,
VenueValidationError,
type GeoSuggestion,
type MicropubVenueStore,
type Venue,
} from "./venues.js";
import { authorize, tokenFromHeader, type AuthEnv } from "./auth.js";
import { syndicateEntry } from "./fediverse.js";

Expand Down Expand Up @@ -188,6 +195,10 @@ function contactsEnabled(config: ResolvedConfig): boolean {
return config.extensions.proposed && config.contacts !== undefined;
}

function venuesEnabled(config: ResolvedConfig): boolean {
return config.extensions.proposed && config.venues !== undefined;
}

function contactInternalUrl(config: ResolvedConfig, id: string): string {
return `${config.micropubEndpoint.replace(/\/$/, "")}/contacts/${encodeURIComponent(id)}`;
}
Expand Down Expand Up @@ -512,6 +523,44 @@ async function handleContactQuery(
}
}

/** Format a venue's coordinates as decimal strings, per common mf2 JSON. */
function venueView(venue: Venue): Record<string, unknown> {
return {
name: venue.name,
latitude: venue.latitude.toFixed(6),
longitude: venue.longitude.toFixed(6),
url: venue.url,
...(venue.description ? { description: venue.description } : {}),
...(venue.category ? { category: venue.category } : {}),
};
}

function geoSuggestionView(geo: GeoSuggestion): Record<string, unknown> {
return {
label: geo.label,
latitude: geo.latitude.toFixed(6),
longitude: geo.longitude.toFixed(6),
};
}

async function handleVenueQuery(
params: URLSearchParams,
store: MicropubVenueStore,
): Promise<Response> {
try {
const query = parseVenueSearchParams(params);
const result = await store.searchNearby(query);
return json({
...(result.geo ? { geo: geoSuggestionView(result.geo) } : {}),
venues: result.venues.map(venueView),
});
} catch (err) {
if (err instanceof VenueValidationError)
return error("invalid_request", err.message, 400);
throw err;
}
}

/** Handle `GET` to the Micropub endpoint: `q=config`/`source`/`syndicate-to`. */
async function handleQuery(
request: Request,
Expand Down Expand Up @@ -546,6 +595,7 @@ async function handleQuery(
const supportedQueries = ["source", "config", "syndicate-to"];
if (config.extensions.stable) supportedQueries.push("category");
if (contactsEnabled(config)) supportedQueries.push("contact");
if (venuesEnabled(config)) supportedQueries.push("geo");
return json({
"media-endpoint": config.mediaEndpoint,
"syndicate-to": await config.syndicateTo(),
Expand Down Expand Up @@ -591,6 +641,9 @@ async function handleQuery(
if (q === "contact" && config.extensions.proposed && config.contacts) {
return handleContactQuery(params, config, config.contacts(env));
}
if (q === "geo" && config.extensions.proposed && config.venues) {
return handleVenueQuery(params, config.venues);
}
if (q === "source") {
const filter = [
...params.getAll("properties[]"),
Expand Down
129 changes: 128 additions & 1 deletion packages/micropub/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { env } from "cloudflare:test";
import { signAccessToken, createIndieAuthStore } from "@dwk/indieauth";
import { beforeEach, describe, expect, it } from "vitest";

import { createMicropub, createMicropubContactStore } from "./index.js";
import {
createMicropub,
createMicropubContactStore,
createMicropubVenueStore,
} from "./index.js";
import type { MicropubEnv } from "./index.js";

const harness = env as unknown as MicropubEnv;
Expand Down Expand Up @@ -150,11 +154,19 @@ const contactsHandler = createMicropub({
extensions: { proposed: true },
contacts: createMicropubContactStore,
});
const venueStore = createMicropubVenueStore(harness);
const venuesHandler = createMicropub({
baseUrl: BASE,
me: ME,
extensions: { proposed: true },
venues: venueStore,
});

beforeEach(async () => {
await createIndieAuthStore(harness).init();
await (await import("./store.js")).createMicropubStore(harness).init();
await contactStore.init();
await venueStore.init();
await (await import("./replay.js")).createDpopReplayStore(harness).init();
});

Expand Down Expand Up @@ -2198,3 +2210,118 @@ describe("@dwk/micropub proposed contacts", () => {
expect(malformed.status).toBe(400);
});
});

describe("@dwk/micropub proposed venues (q=geo)", () => {
it("advertises geo only when a venue store is configured", async () => {
const reader = await mintToken("create");
const withVenues = await venuesHandler(
new Request(`${MICROPUB}?q=config`, {
headers: await authHeaders(reader, "GET", MICROPUB),
}),
harness,
ctx,
);
expect(((await withVenues.json()) as { q: string[] }).q).toContain("geo");
const withoutVenues = await handler(
new Request(`${MICROPUB}?q=config`, {
headers: await authHeaders(reader, "GET", MICROPUB),
}),
harness,
ctx,
);
expect(((await withoutVenues.json()) as { q: string[] }).q).not.toContain(
"geo",
);
});

it("returns nearby venues ordered by distance, with a geo suggestion", async () => {
const reader = await mintToken("create");
const suffix = crypto.randomUUID();
await harness.MICROPUB_DB.prepare(
`INSERT INTO micropub_venues
(id, url, name, latitude, longitude, description, category, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)`,
)
.bind(
`near-${suffix}`,
`${BASE}/venues/near-${suffix}`,
"Near Cafe",
37.786971,
-122.399677,
1,
)
.run();
await harness.MICROPUB_DB.prepare(
`INSERT INTO micropub_venues
(id, url, name, latitude, longitude, description, category, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)`,
)
.bind(
`far-${suffix}`,
`${BASE}/venues/far-${suffix}`,
"Far Diner",
40,
-70,
1,
)
.run();

const res = await venuesHandler(
new Request(`${MICROPUB}?q=geo&lat=37.786971&lon=-122.399677&u=500`, {
headers: await authHeaders(reader, "GET", MICROPUB),
}),
harness,
ctx,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
geo: { label: string; latitude: string; longitude: string };
venues: Array<{
name: string;
url: string;
latitude: string;
longitude: string;
}>;
};
expect(body.geo).toEqual({
label: "37.786971, -122.399677",
latitude: "37.786971",
longitude: "-122.399677",
});
expect(body.venues).toEqual([
{
name: "Near Cafe",
url: `${BASE}/venues/near-${suffix}`,
latitude: "37.786971",
longitude: "-122.399677",
},
]);
});

it("rejects malformed q=geo queries with 400 invalid_request", async () => {
const reader = await mintToken("create");
const res = await venuesHandler(
new Request(`${MICROPUB}?q=geo&lat=999&lon=0`, {
headers: await authHeaders(reader, "GET", MICROPUB),
}),
harness,
ctx,
);
expect(res.status).toBe(400);
expect(((await res.json()) as { error: string }).error).toBe(
"invalid_request",
);
});

it("400s q=geo as an unsupported query when no venue store is configured", async () => {
const reader = await mintToken("create");
const res = await handler(
new Request(`${MICROPUB}?q=geo&lat=0&lon=0`, {
headers: await authHeaders(reader, "GET", MICROPUB),
}),
harness,
ctx,
);
expect(res.status).toBe(400);
});
});
11 changes: 11 additions & 0 deletions packages/micropub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,14 @@ export {

export { MicropubLogEvent } from "./log.js";
export type { Logger, Metrics } from "@dwk/log";
export {
createMicropubVenueStore,
parseVenueSearchParams,
VenueValidationError,
type GeoPoint,
type GeoSuggestion,
type MicropubVenueStore,
type Venue,
type VenueSearchQuery,
type VenueStoreEnv,
} from "./venues.js";
Loading
Loading