diff --git a/README.md b/README.md index fdc4f86..7657940 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,14 @@ and no native dependencies. ## The fragment contract -The fragment schema and the Node response types are vendored from the -`sphere-node` repository into `src/contract/`. That repository is the source of -truth for the contract; this plugin consumes it and does not redefine it. +The fragment schema and the Node response types come from the +[`@sphere-pub/node`](https://github.com/marianoviola/sphere-node) package: the +schema at `@sphere-pub/node/spec/fragment.schema.json` and the types at +`@sphere-pub/node/contract`. It is a build-time dependency only — the schema is +bundled into `server/index.js`, and nothing from the node runs inside the +plugin. That package is the source of truth for the contract; this plugin +consumes it and does not redefine it (a test fails if a vendored copy +reappears under `src/contract/`). ## License diff --git a/package-lock.json b/package-lock.json index 9940fdf..bb5790a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,20 @@ { "name": "sphere-plugin", - "version": "1.0.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sphere-plugin", - "version": "1.0.0", - "license": "Apache-2.0", + "version": "1.1.1", + "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.23.8" }, "devDependencies": { "@anthropic-ai/mcpb": "^1.0.0", + "@sphere-pub/node": "^1.1.0", "@types/node": "^22.10.0", "esbuild": "^0.24.0", "sharp": "^0.33.0", @@ -1566,6 +1567,19 @@ "win32" ] }, + "node_modules/@sphere-pub/node": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@sphere-pub/node/-/node-1.1.0.tgz", + "integrity": "sha512-OOn4xIxqvMrOpULLQYofzcQD+6pBHs5rv4BiiWXntRTLzzMB0AHzuwNjgIwfya34+hFYfos3sy+7v088hEPmNA==", + "dev": true, + "license": "MIT", + "bin": { + "sphere-node": "dist/cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", diff --git a/package.json b/package.json index df845f4..b2b1dc3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@anthropic-ai/mcpb": "^1.0.0", + "@sphere-pub/node": "^1.1.0", "@types/node": "^22.10.0", "esbuild": "^0.24.0", "sharp": "^0.33.0", diff --git a/src/contract/fragment.schema.json b/src/contract/fragment.schema.json deleted file mode 100644 index 7310157..0000000 --- a/src/contract/fragment.schema.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "$comment": "Vendored from sphere-node (spec/fragment.schema.json). Source of truth lives there. Do not edit here except to re-sync.", - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://sphere.pub/spec/fragment.schema.json", - "title": "Sphere Fragment Manifest", - "description": "Contract for a single agent-readable fragment manifest (sphere.json). This file is the source of truth shared by the node, the publish script, and any external authoring tool.", - "type": "object", - "required": ["id", "title", "license", "access"], - "additionalProperties": true, - "properties": { - "id": { - "type": "string", - "description": "Fragment id, also the URL path segment. Format yyyy-mm-dd-title-slug.", - "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9][a-z0-9-]*$" - }, - "title": { - "type": "string", - "minLength": 1 - }, - "summary": { - "type": "string" - }, - "license": { - "type": "string", - "description": "SPDX-style or named license, e.g. CC-BY." - }, - "access": { - "type": "object", - "required": ["policy"], - "additionalProperties": true, - "properties": { - "policy": { - "type": "string", - "enum": ["free", "metered", "paid", "sponsored"] - }, - "preview_chars": { - "type": "integer", - "minimum": 0, - "description": "Number of leading characters of content.md served as a free preview for gated policies." - }, - "price_per_access": { - "type": "number", - "minimum": 0 - }, - "currency": { - "type": "string", - "description": "ISO 4217 currency code, e.g. USD." - }, - "payment": { - "type": "object", - "description": "Payment metadata used to build the 402 challenge. Required for paid and metered policies.", - "required": ["profile", "method", "endpoint"], - "additionalProperties": true, - "properties": { - "profile": { "type": "string" }, - "method": { "type": "string" }, - "endpoint": { "type": "string" } - } - } - } - }, - "sources": { - "type": "array", - "description": "Typed EXTERNAL provenance: the books, articles, papers, videos, datasets, and pages this fragment draws on. Optional; may be empty. External only — this is NOT the internal document a fragment was generated from (that is build lineage, not part of the contract).", - "items": { - "type": "object", - "required": ["type", "title"], - "additionalProperties": true, - "properties": { - "type": { - "type": "string", - "enum": ["book", "article", "paper", "video", "webpage", "dataset", "other"] - }, - "title": { "type": "string", "minLength": 1 }, - "author": { "type": "string" }, - "url": { "type": "string" }, - "date": { "type": "string" }, - "note": { "type": "string" } - } - } - }, - "relations": { - "type": "array", - "description": "Typed edges to other fragments. Each edge is { type, target }: `type` is a short, open relation kind (e.g. related, continues, cites, responds-to) and `target` is a CANONICAL fragment reference — either a same-node fragment id (yyyy-mm-dd-slug) or an absolute URL to another node's canonical fragment ({node_base}/fragments/{id}). See node-api.md for the reference scheme. Optional; may be empty.", - "items": { - "type": "object", - "required": ["type", "target"], - "additionalProperties": true, - "properties": { - "type": { - "type": "string", - "minLength": 1, - "description": "Relation kind: a short free string (e.g. related, continues, cites, responds-to). Not enumerated; kept open." - }, - "target": { - "type": "string", - "minLength": 1, - "description": "Canonical fragment reference: a same-node id (yyyy-mm-dd-slug) or an absolute external fragment URL ({node_base}/fragments/{id})." - } - } - } - } - } -} diff --git a/src/contract/node-api.ts b/src/contract/node-api.ts deleted file mode 100644 index a096290..0000000 --- a/src/contract/node-api.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Vendored from sphere-node. Source of truth lives there. -// -// These are the response shapes of the Sphere Node owner face, derived from -// sphere-node/spec/node-api.md. This plugin only READS these endpoints; it never -// defines or serves them. Keep in sync with sphere-node; do not extend here. - -/** Access policies a fragment can declare (mirrors fragment.schema.json). */ -export type AccessPolicy = "free" | "metered" | "paid" | "sponsored"; - -/** 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; - }>; -} - -/** A single payment row (dormant in node v1; shape present). */ -export interface PaymentRecord { - ts: number; - fragmentId: string | null; - amount: number; - currency: string | null; - profile: string | null; - status: string; -} - -/** GET /owner/payments */ -export interface PaymentStatus { - payments: PaymentRecord[]; - total: number; -} - -/** External provenance kinds a fragment may draw on. */ -export type SourceType = "book" | "article" | "paper" | "video" | "webpage" | "dataset" | "other"; - -/** - * A typed EXTERNAL source: a book, article, paper, video, page, or dataset a - * fragment draws on. Provenance is legitimacy, so this is part of the contract. - * NOT the internal document a fragment was generated from (build lineage). - */ -export interface SourceRef { - type: SourceType; - title: string; - author?: string; - url?: string; - date?: string; - note?: string; -} - -/** - * A typed edge to another fragment. `type` is a short, open relation kind - * (related, continues, cites, responds-to, ...). `target` is a CANONICAL - * fragment reference: a same-node id (yyyy-mm-dd-slug) or an absolute external - * fragment URL ({node_base}/fragments/{id}). The reference scheme is canonical - * in sphere-node (spec/node-api.md); this plugin never redefines it. - */ -export interface RelationEdge { - type: string; - target: string; - [k: string]: unknown; -} - -/** Minimal manifest shape the plugin reads for validation and readiness. */ -export interface FragmentManifest { - id: string; - title: string; - summary?: string; - license: string; - access: { - policy: AccessPolicy; - preview_chars?: number; - price_per_access?: number; - currency?: string; - payment?: { - profile: string; - method: string; - endpoint: string; - /** x402 (github.com/coinbase/x402): CAIP-2 network id, e.g. "eip155:8453". */ - network?: string; - /** x402: the asset (token contract or symbol) payment is accepted in. */ - asset?: string; - /** x402: the recipient address `accepts[].payTo`. */ - pay_to?: string; - /** x402: override for `accepts[].amount` when price_per_access isn't already in the asset's atomic units. */ - amount?: string; - /** x402: `accepts[].maxTimeoutSeconds`. */ - max_timeout_seconds?: number; - [k: string]: unknown; - }; - [k: string]: unknown; - }; - sources?: SourceRef[]; - relations?: RelationEdge[]; - // Optional fields the readiness checks look for (not required by the schema). - canonical_url?: string; - media?: unknown[]; - data?: unknown[]; - [k: string]: unknown; -} diff --git a/src/core/fragmentIo.ts b/src/core/fragmentIo.ts index 63b9900..d305996 100644 --- a/src/core/fragmentIo.ts +++ b/src/core/fragmentIo.ts @@ -4,7 +4,7 @@ import { readFile, readdir, stat } from "node:fs/promises"; import { join, basename } from "node:path"; -import type { FragmentManifest } from "../contract/node-api"; +import type { FragmentManifest } from "@sphere-pub/node/contract"; export interface LoadedFragment { /** Directory that holds sphere.json. */ diff --git a/src/core/prepare.ts b/src/core/prepare.ts index 9af0830..ef804ba 100644 --- a/src/core/prepare.ts +++ b/src/core/prepare.ts @@ -4,7 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import type { AccessPolicy, FragmentManifest, RelationEdge, SourceRef } from "../contract/node-api"; +import type { AccessPolicy, FragmentManifest, RelationEdge, SourceRef } from "@sphere-pub/node/contract"; import { validateManifest, type ValidationResult } from "./validate"; export interface PrepareInput { diff --git a/src/core/readiness.ts b/src/core/readiness.ts index b7e27f0..c1f58c8 100644 --- a/src/core/readiness.ts +++ b/src/core/readiness.ts @@ -3,7 +3,7 @@ // with a severity and a one-line suggestion. Operates on the manifest plus the // optional content.md text. No network. -import type { FragmentManifest } from "../contract/node-api"; +import type { FragmentManifest } from "@sphere-pub/node/contract"; export type Severity = "info" | "low" | "medium" | "high"; diff --git a/src/core/validate.ts b/src/core/validate.ts index be35034..47133d1 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -1,10 +1,11 @@ -// Structural validation of a fragment manifest against the vendored schema. +// Structural validation of a fragment manifest against the contract schema +// shipped by @sphere-pub/node (the source of truth; nothing is vendored here). // Pass/fail plus a flat list of structural errors. Hard errors only; advisory // gaps live in readiness.ts. -import schema from "../contract/fragment.schema.json"; +import schema from "@sphere-pub/node/spec/fragment.schema.json"; import { validate as validateAgainstSchema, type JsonSchema } from "./schema"; -import type { FragmentManifest } from "../contract/node-api"; +import type { FragmentManifest } from "@sphere-pub/node/contract"; export interface ValidationResult { ok: boolean; diff --git a/tests/contract.test.ts b/tests/contract.test.ts new file mode 100644 index 0000000..efb039a --- /dev/null +++ b/tests/contract.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import schema from "@sphere-pub/node/spec/fragment.schema.json"; + +// The fragment contract has exactly one home, the @sphere-pub/node package. +// This plugin used to vendor a copy under src/contract/; these tests keep it +// from coming back and make sure the schema in the bundle is the package's. +describe("fragment contract", () => { + it("is not vendored in this repository", () => { + const vendored = fileURLToPath(new URL("../src/contract", import.meta.url)); + expect(existsSync(vendored)).toBe(false); + }); + + it("comes from @sphere-pub/node", () => { + expect(schema.$id).toBe("https://sphere.pub/spec/fragment.schema.json"); + expect(schema.title).toBe("Sphere Fragment Manifest"); + expect(schema.required).toEqual(["id", "title", "license", "access"]); + }); +});