diff --git a/README.md b/README.md index efa7246..d8f8188 100644 --- a/README.md +++ b/README.md @@ -166,10 +166,15 @@ wrangler secret put SPHERE_OWNER_TOKEN wrangler deploy ``` -The package also exposes the contract (`@sphere-pub/node/spec/fragment.schema.json`, -`@sphere-pub/node/spec/node-api.md`) and a `sphere-node` bin that wraps the -publish script below (`npx sphere-node [--remote]`). The -`sphere-pub` repository is a complete, minimal example of this setup. +The package also exposes the contract — the schema at +`@sphere-pub/node/spec/fragment.schema.json`, `@sphere-pub/node/spec/node-api.md`, +and the TypeScript types at `@sphere-pub/node/contract` (manifest, sources, +relations, owner-API responses; no Cloudflare dependencies) — and a +`sphere-node` bin that wraps the publish script below +(`npx sphere-node [--remote]`). Tools that read or write +fragments, such as the Sphere plugin, consume the contract from here instead +of vendoring it. The `sphere-pub` repository is a complete, minimal example of +an instance. ## Deploy diff --git a/package.json b/package.json index 596b1c7..f7e7197 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sphere-pub/node", - "version": "1.0.0", + "version": "1.1.0", "description": "Single-tenant, self-hosted Sphere Node: publishes a publisher's content as agent-readable fragments on Cloudflare Workers.", "type": "module", "license": "MIT", @@ -37,6 +37,10 @@ "types": "./dist/types/src/index.d.ts", "default": "./dist/index.js" }, + "./contract": { + "types": "./dist/types/src/contract.d.ts", + "default": "./dist/contract.js" + }, "./spec/fragment.schema.json": "./spec/fragment.schema.json", "./spec/*": "./spec/*", "./migrations/*": "./migrations/*", diff --git a/scripts/build.mjs b/scripts/build.mjs index 82a442c..05c8ae6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -18,6 +18,17 @@ await build({ sourcemap: true, }); +// Contract entry: types only. The runtime module is intentionally empty; it +// exists so "@sphere-pub/node/contract" resolves for bundlers as well as tsc. +await build({ + entryPoints: ["src/contract.ts"], + outfile: "dist/contract.js", + bundle: true, + format: "esm", + platform: "neutral", + target: "es2022", +}); + // Publish CLI: runs under Node and shells out to wrangler. It reads // spec/fragment.schema.json relative to its own location (dist/ -> ../spec/), // so the spec directory ships alongside it in the package. diff --git a/spec/node-api.md b/spec/node-api.md index cb418de..f26e57b 100644 --- a/spec/node-api.md +++ b/spec/node-api.md @@ -255,9 +255,14 @@ Ledger event: `access` (free) or `preview` (gated). ## Owner face (bearer token, single owner) -All owner endpoints require `Authorization: Bearer ` and are -read-only. Missing or wrong token returns `401`. Owner requests do NOT append -ledger events. +All owner endpoints require `Authorization: Bearer `. +Missing or wrong token returns `401`. Owner requests do NOT append ledger +events. Every owner endpoint is read-only except the publish route. + +The response shapes below are exported as TypeScript types from +`@sphere-pub/node/contract` (`PublisherSummary`, `FragmentUsage`, +`PaymentStatus`, `PublishResult`); the worker handlers are typed against the +same definitions. ### `GET /owner/summary` @@ -292,6 +297,36 @@ Payment ledger. Empty in v1; the shape is present for forward compatibility. { "payments": [], "total": 0 } ``` +### `PUT /owner/fragments/{id}` + +Publish (upsert) one fragment. The one write in the contract: it drives the +same validation and storage the CLI drives. Body is JSON, at most 1,000,000 +bytes: + +```json +{ + "manifest": { "id": "2026-01-15-sample-fragment", "title": "...", "license": "CC-BY", "access": { "policy": "free" } }, + "content": "the full content.md body", + "media": [{ "name": "figure.svg", "content": "" }] +} +``` + +`manifest` is validated against `fragment.schema.json` plus the access rules +(gated policies need `access.payment` and a positive price); `manifest.id` must +equal `{id}` in the path. `media` is optional; each entry is a text file stored +under the fragment's media prefix. + +- `200` — `{ id, canonical, mediaCount, updatedTs }` (`PublishResult`). + `canonical` is the fragment's absolute canonical URL on this node. +- `400` — `{ error }` with `invalid_json`, `manifest_required`, + `id_mismatch`, `content_required`, or `media_invalid`. +- `413` — `{ error: "payload_too_large", limit }`. +- `422` — `{ errors: [...] }`, the schema/access-rule violations. + +A successful publish drops the cached discovery document, so +`/.well-known/sphere.json` lists the fragment on the next read rather than +after the cache TTL. + ## Ledger events Every public request appends exactly one row: `ts, fragment_id, event_type, diff --git a/src/contract.ts b/src/contract.ts new file mode 100644 index 0000000..7611eae --- /dev/null +++ b/src/contract.ts @@ -0,0 +1,21 @@ +// The fragment contract as types, with no Cloudflare or runtime dependencies. +// +// Consumers that only need to READ or WRITE fragments — the authoring plugin, +// an importer, a CLI — import from "@sphere-pub/node/contract" and the schema +// from "@sphere-pub/node/spec/fragment.schema.json", instead of vendoring +// either. The JSON Schema stays the source of truth for structure; these types +// mirror it for TypeScript consumers. + +export type { + AccessBlock, + AccessPolicy, + FragmentManifest, + PaymentMetadata, + PublisherRef, + RelationEdge, + SourceRef, + SourceType, + StoredFragment, +} from "./core/types.ts"; +export type { PaymentRecord } from "./core/ports.ts"; +export type { FragmentUsage, PaymentStatus, PublisherSummary, PublishResult } from "./core/owner-api.ts"; diff --git a/src/core/owner-api.ts b/src/core/owner-api.ts new file mode 100644 index 0000000..273cadd --- /dev/null +++ b/src/core/owner-api.ts @@ -0,0 +1,51 @@ +// Response shapes of the owner face (spec/node-api.md, "Owner face"). The +// worker handlers are typed against these, and the authoring plugin reads them +// through the package's `contract` entry, so the two sides cannot drift apart +// silently. Zero Cloudflare names or imports. + +import type { PaymentRecord } from "./ports.ts"; + +/** GET /owner/summary */ +export interface PublisherSummary { + publisher: string; + fragment_count: number; + events: { + total: number; + by_type: Record; + }; + top_fragments: Array<{ + id: string; + title: string | null; + events: number; + }>; + revenue: { + total: number; + currency: string; + payments: number; + }; +} + +/** GET /owner/fragments/{id}/usage */ +export interface FragmentUsage { + fragment_id: string; + points: Array<{ + day: string; + event_type: string; + count: number; + }>; +} + +/** GET /owner/payments — dormant in v1: `payments` is empty and `total` is 0. */ +export interface PaymentStatus { + payments: PaymentRecord[]; + total: number; +} + +/** PUT /owner/fragments/{id} — success body. */ +export interface PublishResult { + id: string; + /** The fragment's absolute canonical URL on this node. */ + canonical: string; + mediaCount: number; + updatedTs: number; +} diff --git a/src/core/ports.ts b/src/core/ports.ts index 80199c8..8954f9a 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -15,6 +15,7 @@ export interface BlobStore { export interface KvStore { get(key: string): Promise; put(key: string, value: string, options?: { expirationTtl?: number }): Promise; + delete(key: string): Promise; } export interface UsagePoint { diff --git a/src/index.ts b/src/index.ts index a5cc068..ec18564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,5 +18,5 @@ export { type NodeConfig, type RequestContext, } from "./platform/cloudflare/worker.ts"; -export type { FragmentManifest, PublisherRef, StoredFragment } from "./core/types.ts"; export type { JsonSchema } from "./core/schema.ts"; +export * from "./contract.ts"; diff --git a/src/platform/cloudflare/adapters.ts b/src/platform/cloudflare/adapters.ts index c9c856c..664d96e 100644 --- a/src/platform/cloudflare/adapters.ts +++ b/src/platform/cloudflare/adapters.ts @@ -37,6 +37,9 @@ export function kvStore(ns: KVNamespace): KvStore { async put(key, value, options) { await ns.put(key, value, options); }, + async delete(key) { + await ns.delete(key); + }, }; } diff --git a/src/platform/cloudflare/worker.ts b/src/platform/cloudflare/worker.ts index 9ee4338..aa30d12 100644 --- a/src/platform/cloudflare/worker.ts +++ b/src/platform/cloudflare/worker.ts @@ -14,6 +14,7 @@ import { renderRobotsTxt } from "../../core/robots.ts"; import { renderSitemap } from "../../core/sitemap.ts"; import type { JsonSchema } from "../../core/schema.ts"; import type { FragmentManifest, PublisherRef } from "../../core/types.ts"; +import type { FragmentUsage, PaymentStatus, PublisherSummary, PublishResult } from "../../core/owner-api.ts"; // The publish route validates against the SAME contract the CLI uses. esbuild // (wrangler) and vitest both bundle this JSON import, so the schema ships inside // the Worker rather than being read from disk at runtime. @@ -404,26 +405,29 @@ async function handleOwnerSummary(deps: Deps): Promise { }), ); - return json({ + const body: PublisherSummary = { publisher: deps.config.publisherName, fragment_count: fragmentCount, events: { total: summary.total, by_type: summary.byType }, top_fragments: topWithTitles, revenue: { total: paymentTotal, currency: "USD", payments: payments.length }, - }); + }; + return json(body); } async function handleOwnerUsage(deps: Deps, id: string): Promise { const points = await deps.events.usageForFragment(id); - return json({ + const body: FragmentUsage = { fragment_id: id, points: points.map((p) => ({ day: p.day, event_type: p.eventType, count: p.count })), - }); + }; + return json(body); } async function handleOwnerPayments(deps: Deps): Promise { const [payments, total] = await Promise.all([deps.payments.list(), deps.payments.total()]); - return json({ payments, total }); + const body: PaymentStatus = { payments, total }; + return json(body); } /** Cap on the publish body: enough for a long fragment, small enough to reject abuse. */ @@ -506,8 +510,17 @@ async function handleOwnerPublish(deps: Deps, request: Request, id: string): Pro await deps.blobs.put(mediaKeyFor(typed.id, item.name), item.content); } await deps.fragments.upsert(stored); - - return json({ id: typed.id, canonical: canonicalFor(request, typed.id), mediaCount: media.length, updatedTs }); + // The discovery document lists every fragment; drop the cached copy so the + // next read reflects this publish instead of waiting out the TTL. + await deps.cache.delete(DISCOVERY_CACHE_KEY); + + const result: PublishResult = { + id: typed.id, + canonical: canonicalFor(request, typed.id), + mediaCount: media.length, + updatedTs, + }; + return json(result); } /** diff --git a/tests/helpers.ts b/tests/helpers.ts index fbf69c2..276f5af 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -40,6 +40,9 @@ export function memKvStore(): KvStore & { dump: Map } { async put(key, value) { dump.set(key, value); }, + async delete(key) { + dump.delete(key); + }, }; } diff --git a/tests/owner-publish.test.ts b/tests/owner-publish.test.ts index 41858c9..5e500e7 100644 --- a/tests/owner-publish.test.ts +++ b/tests/owner-publish.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { handleRequest } from "../src/platform/cloudflare/worker.ts"; import { mediaKeyFor } from "../src/core/fragments.ts"; -import { makeDeps, testCtx, get, readJson } from "./helpers.ts"; +import { makeDeps, memKvStore, testCtx, get, readJson } from "./helpers.ts"; const AUTH = { authorization: "Bearer secret-owner-token" }; @@ -61,6 +61,30 @@ describe("owner publish route", () => { expect(await content.text()).toBe("the full published body"); }); + it("invalidates the cached discovery document so the publish is visible at once", async () => { + const cache = memKvStore(); + const deps = makeDeps({ cache }); + + // Warm the discovery cache on an empty node. + const before = await readJson(await handleRequest(get("/.well-known/sphere.json"), deps, testCtx())); + expect(before.fragment_count).toBe(0); + expect(cache.dump.has("discovery:v1")).toBe(true); + + const res = await handleRequest( + put("/owner/fragments/2026-02-01-published", { manifest: freeManifest, content: "body" }), + deps, + testCtx(), + ); + expect(res.status).toBe(200); + + // The cached copy is gone, and the next read lists the new fragment + // instead of serving the stale document until its TTL runs out. + expect(cache.dump.has("discovery:v1")).toBe(false); + const after = await readJson(await handleRequest(get("/.well-known/sphere.json"), deps, testCtx())); + expect(after.fragment_count).toBe(1); + expect(after.fragments[0].id).toBe("2026-02-01-published"); + }); + it("appends no ledger event for a publish (owner write, not an access)", async () => { const deps = makeDeps(); const ctx = testCtx();