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
6 changes: 6 additions & 0 deletions .changeset/micropub-contacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@dwk/micropub": minor
---

Add the opt-in proposed Contacts (`q=contact`) extension with a private,
injectable h-card store, lifecycle actions, filtering, and pagination.
5 changes: 4 additions & 1 deletion packages/micropub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ in D1, and backs its media endpoint with R2.
## Usage

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

const micropub = createMicropub({
baseUrl: "https://example.com",
Expand All @@ -29,6 +29,7 @@ const micropub = createMicropub({
// serving/access-control layer; Micropub itself does not enforce them.
extensions: { proposed: true },
audiences: [{ uid: "family", name: "Family" }],
contacts: createMicropubContactStore,
});

export default {
Expand Down Expand Up @@ -56,6 +57,8 @@ The handler fails loudly at startup if any of these are missing:
- **Media endpoint**: streams uploads to R2 and serves them back.
- **Queries**: `q=config`, `q=source` (with a `properties[]` filter), and
`q=syndicate-to`.
- **Opt-in Contacts** (`q=contact`): a private h-card address book with
filtered, paginated listing and create/update/delete lifecycle actions.
- **Opt-in proposed metadata**: named private-post `audience` values and
`location-visibility` (`public`, `private`, or textual-only `text`). They
are persisted and returned by `q=source`; the site or WAC layer enforces
Expand Down
25 changes: 25 additions & 0 deletions packages/micropub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { canonicalizeProfileUrl } from "@dwk/indieauth";
import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";

import type { FediverseSyndicationConfig } from "./fediverse.js";
import type {
MicropubContactStore,
MicropubContactStoreEnv,
} from "./contacts.js";
import type { Mf2Object, MicropubCommands } from "./mf2.js";

/**
Expand Down Expand Up @@ -80,6 +84,11 @@ export interface SyndicationTarget {
export type SyndicationTargetsProvider = () =>
Promise<readonly SyndicationTarget[]> | readonly SyndicationTarget[];

/** Builds a request-bound Contacts store from the composed Worker bindings. */
export type MicropubContactStoreProvider = (
env: MicropubContactStoreEnv,
) => MicropubContactStore;

/**
* Derive the canonical URL of a newly created post from its microformats2
* object and the parsed `mp-*` commands. Returning a relative path is allowed;
Expand Down Expand Up @@ -124,6 +133,11 @@ export interface MicropubConfig {
* or enforce access control for any audience.
*/
readonly audiences?: readonly AudienceConfig[];
/**
* Private h-card store for the proposed Contacts extension. Contacts are
* advertised only when this is set and the proposed group is enabled.
*/
readonly contacts?: MicropubContactStore | MicropubContactStoreProvider;
/**
* 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 @@ -194,6 +208,8 @@ export interface ResolvedConfig {
readonly audiences: readonly AudienceConfig[];
/** Precomputed membership set for validating proposed audience IDs. */
readonly audienceIds: ReadonlySet<string>;
/** Normalized Contacts store provider, when the extension is configured. */
readonly contacts?: MicropubContactStoreProvider;
readonly postTypes?: readonly PostTypeConfig[];
/** Normalized to an async provider regardless of the configured shape. */
readonly syndicateTo: () => Promise<readonly SyndicationTarget[]>;
Expand Down Expand Up @@ -256,6 +272,13 @@ function normalizeSyndicateTo(
return async () => syndicateTo;
}

function normalizeContactStore(
contacts: MicropubContactStore | MicropubContactStoreProvider | undefined,
): MicropubContactStoreProvider | undefined {
if (contacts === undefined) return undefined;
return typeof contacts === "function" ? contacts : () => contacts;
}

function pathOf(absoluteUrl: string, label: string): string {
try {
return new URL(absoluteUrl).pathname;
Expand Down Expand Up @@ -285,6 +308,7 @@ export function resolveConfig(config: MicropubConfig): ResolvedConfig {

const micropubEndpoint = config.micropubEndpoint ?? `${origin}/micropub`;
const mediaEndpoint = config.mediaEndpoint ?? `${origin}/media`;
const contactStore = normalizeContactStore(config.contacts);
const audiences = config.audiences ?? [];
const audienceIds = new Set<string>();
for (const audience of audiences) {
Expand Down Expand Up @@ -319,6 +343,7 @@ export function resolveConfig(config: MicropubConfig): ResolvedConfig {
},
audiences,
audienceIds,
...(contactStore ? { contacts: contactStore } : {}),
...(config.postTypes ? { postTypes: config.postTypes } : {}),
syndicateTo: normalizeSyndicateTo(config.syndicateTo),
...(config.fediverse ? { fediverse: config.fediverse } : {}),
Expand Down
38 changes: 38 additions & 0 deletions packages/micropub/src/contacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { env } from "cloudflare:test";
import { describe, expect, it } from "vitest";

import {
canonicalContactUrl,
contactWrite,
createMicropubContactStore,
type MicropubContactStoreEnv,
} from "./contacts.js";

const harness = env as unknown as MicropubContactStoreEnv;

describe("Micropub contact store", () => {
it("canonicalizes, searches nested unknown properties, and orders deterministically", async () => {
expect(canonicalContactUrl({ url: ["HTTPS://EXAMPLE.COM:443"] })).toBe(
"https://example.com/",
);
const store = createMicropubContactStore(harness);
const suffix = crypto.randomUUID();
const beta = contactWrite(
`beta-${suffix}`,
{ name: ["Beta"], note: [{ value: "Needle" }] },
1,
);
const alpha = contactWrite(`alpha-${suffix}`, { name: ["Alpha"] }, 1);
expect(await store.create(beta)).toBe("created");
expect(await store.create(alpha)).toBe("created");
expect(
(await store.list({ filter: "needle", limit: 10, offset: 0 })).map(
(contact) => contact.id,
),
).toEqual([beta.id]);
const ours = (await store.list({ limit: 10, offset: 0 })).filter(
(contact) => contact.id.endsWith(suffix),
);
expect(ours.map((contact) => contact.id)).toEqual([alpha.id, beta.id]);
});
});
Loading
Loading