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
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fragment-dir> [--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 <fragment-dir> [--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

Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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/*",
Expand Down
11 changes: 11 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 38 additions & 3 deletions spec/node-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,14 @@ Ledger event: `access` (free) or `preview` (gated).

## Owner face (bearer token, single owner)

All owner endpoints require `Authorization: Bearer <SPHERE_OWNER_TOKEN>` and are
read-only. Missing or wrong token returns `401`. Owner requests do NOT append
ledger events.
All owner endpoints require `Authorization: Bearer <SPHERE_OWNER_TOKEN>`.
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`

Expand Down Expand Up @@ -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": "<svg .../>" }]
}
```

`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,
Expand Down
21 changes: 21 additions & 0 deletions src/contract.ts
Original file line number Diff line number Diff line change
@@ -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";
51 changes: 51 additions & 0 deletions src/core/owner-api.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
};
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;
}
1 change: 1 addition & 0 deletions src/core/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface BlobStore {
export interface KvStore {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
delete(key: string): Promise<void>;
}

export interface UsagePoint {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
3 changes: 3 additions & 0 deletions src/platform/cloudflare/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
};
}

Expand Down
27 changes: 20 additions & 7 deletions src/platform/cloudflare/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -404,26 +405,29 @@ async function handleOwnerSummary(deps: Deps): Promise<Response> {
}),
);

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<Response> {
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<Response> {
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. */
Expand Down Expand Up @@ -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);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export function memKvStore(): KvStore & { dump: Map<string, string> } {
async put(key, value) {
dump.set(key, value);
},
async delete(key) {
dump.delete(key);
},
};
}

Expand Down
26 changes: 25 additions & 1 deletion tests/owner-publish.test.ts
Original file line number Diff line number Diff line change
@@ -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" };

Expand Down Expand Up @@ -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();
Expand Down
Loading