feat: add OpenFleet membership and CoinPay rental contracts

This commit is contained in:
Anthony Ettinger 2026-09-13 06:53:21 +00:00
parent a333726a1e
commit b6cd7cfde2
16 changed files with 933 additions and 2 deletions

View file

@ -11,7 +11,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run src",
"validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json"
"validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openfleet ../schemas/fixtures/openfleet/mixed.json"
},
"dependencies": {
"@logicsrc/schemas": "^0.1.0",

View file

@ -3,6 +3,7 @@ import * as addFormatsModule from "ajv-formats";
import type { ErrorObject } from "ajv";
import { parse } from "yaml";
import { isSchemaKind, schemas, type SchemaKind } from "./schemas.js";
import { validateOpenFleetReferences } from "./openfleet.js";
type CompiledSchema = { (data: unknown): boolean; errors?: ErrorObject[] | null };
@ -61,6 +62,10 @@ export function validate(kind: SchemaKind, data: unknown): ValidationResult {
const ok = validateDocument(data);
if (ok) {
if (kind === "openfleet") {
const errors = validateOpenFleetReferences(data);
if (errors.length) return { ok: false, kind, errors };
}
return { ok: true, kind, data };
}

View file

@ -0,0 +1,141 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { assertSchemaKind, createValidator, schemas, validate } from "./index.js";
function fixture() {
return JSON.parse(readFileSync(new URL("../../schemas/fixtures/openfleet/mixed.json", import.meta.url), "utf8"));
}
function errorAt(data: unknown, keyword: string, path: string) {
const result = validate("openfleet", data);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.errors).toContainEqual(expect.objectContaining({ keyword, instancePath: path }));
}
describe("OpenFleet", () => {
it("registers a publicly exported schema and validates the mixed fixture", () => {
expect(assertSchemaKind("openfleet")).toBe("openfleet");
expect(validate("openfleet", fixture()).ok).toBe(true);
const ajv = createValidator();
ajv.addSchema(schemas.agent);
expect(ajv.compile(schemas.openfleet)(fixture())).toBe(true);
});
it("validates the complete descriptor in the public specification", () => {
const doc = readFileSync(new URL("../../../docs/openfleet.md", import.meta.url), "utf8");
const example = doc.match(/```json\n([\s\S]*?)\n```/);
expect(example).not.toBeNull();
expect(validate("openfleet", JSON.parse(example![1])).ok).toBe(true);
});
it.each(["openagent", "openswarm"])("accepts a fleet containing only %s members", (kind) => {
const fleet = fixture();
fleet.members = fleet.members.filter((member: { kind: string }) => member.kind === kind);
// A whole-fleet rate applies to either single-kind fleet without expansion.
fleet.rentals = [fleet.rentals[0]];
expect(validate("openfleet", fleet).ok).toBe(true);
});
it("accepts membership without rental offers and preserves extension metadata", () => {
const fleet = fixture();
delete fleet.rentals;
fleet.metadata = { custom: { nested: [null, true, 42, "value"] } };
const snapshot = structuredClone(fleet);
expect(validate("openfleet", fleet).ok).toBe(true);
expect(fleet).toEqual(snapshot);
});
it.each([
["no members", (f: ReturnType<typeof fixture>) => { f.members = []; }],
["unsupported kind", (f: ReturnType<typeof fixture>) => { f.members[0].kind = "openfleet"; }],
["agent without identity", (f: ReturnType<typeof fixture>) => { delete f.members[0].id; }],
["swarm without identity", (f: ReturnType<typeof fixture>) => { delete f.members[1].id; }],
["swarm with an agent DID", (f: ReturnType<typeof fixture>) => { f.members[1].id = "analyst.coinpay"; }],
["agent with a swarm key", (f: ReturnType<typeof fixture>) => { f.members[0].id = f.members[1].id; }],
["short swarm key", (f: ReturnType<typeof fixture>) => { f.members[1].id = "ed25519:abcd"; }],
["non-HTTPS member URL", (f: ReturnType<typeof fixture>) => { f.members[0].url = "http://example.com/agent"; }],
["unsupported version", (f: ReturnType<typeof fixture>) => { f.version = "9.0"; }],
["unknown contract field", (f: ReturnType<typeof fixture>) => { f.rental = []; }]
])("rejects %s", (_, mutate) => {
const fleet = fixture();
mutate(fleet);
expect(validate("openfleet", fleet).ok).toBe(false);
});
it("rejects duplicate member identities even when URL and metadata differ", () => {
const fleet = fixture();
fleet.members.push({ ...fleet.members[0], url: "https://elsewhere.example/agent", metadata: { alias: true } });
errorAt(fleet, "uniqueMember", "/members/2");
});
it("rejects duplicate rental IDs", () => {
const fleet = fixture();
fleet.rentals[1].id = fleet.rentals[0].id;
errorAt(fleet, "uniqueRental", "/rentals/1/id");
});
it("rejects dangling references after a member is removed", () => {
const fleet = fixture();
fleet.members.shift();
errorAt(fleet, "memberReference", "/rentals/1/scope/members/0");
});
it("accepts a rental scoped to both kinds without counting a member twice", () => {
const fleet = fixture();
fleet.rentals[1].scope.members.push({ kind: "openswarm", id: fleet.members[1].id });
expect(validate("openfleet", fleet).ok).toBe(true);
fleet.rentals[1].scope.members.push({ ...fleet.rentals[1].scope.members[0] });
errorAt(fleet, "uniqueMember", "/rentals/1/scope/members/2");
});
it.each([25, -1, "-1.000000", "01.000000", "1e3", "NaN", "Infinity", "1.00", "0.0000001"])(
"rejects an inexact or invalid amount %s", (amount) => {
const fleet = fixture();
fleet.rentals[0].rate.amount = amount;
expect(validate("openfleet", fleet).ok).toBe(false);
}
);
it.each(["0.000000", "0.000001", "9007199254740993.123456"])("preserves exact amount %s", (amount) => {
const fleet = fixture();
fleet.rentals[0].rate.amount = amount;
expect(validate("openfleet", fleet).ok).toBe(true);
expect(fleet.rentals[0].rate.amount).toBe(amount);
});
it.each([
["non-CoinPay provider", (r: ReturnType<typeof fixture>) => { r.payment.provider = "other"; }],
["missing CoinPay metadata", (r: ReturnType<typeof fixture>) => { delete r.payment; }],
["missing payee", (r: ReturnType<typeof fixture>) => { delete r.payment.payee_did; }],
["missing checkout", (r: ReturnType<typeof fixture>) => { delete r.payment.checkout_url; }],
["insecure checkout", (r: ReturnType<typeof fixture>) => { r.payment.checkout_url = "http://example.com/checkout"; }],
["unsupported currency", (r: ReturnType<typeof fixture>) => { r.rate.currency = "EUR"; }],
["missing unit", (r: ReturnType<typeof fixture>) => { delete r.rate.unit; }],
["unsupported unit", (r: ReturnType<typeof fixture>) => { r.rate.unit = "second"; }],
["fractional billing units", (r: ReturnType<typeof fixture>) => { r.minimum_units = 0.5; }],
["unsafe integer units", (r: ReturnType<typeof fixture>) => { r.maximum_units = 9007199254740992; }],
["empty member scope", (r: ReturnType<typeof fixture>) => { r.scope = { kind: "members", members: [] }; }],
["ambiguous fleet scope", (r: ReturnType<typeof fixture>) => { r.scope.members = []; }]
])("rejects a rental with %s", (_, mutate) => {
const fleet = fixture();
mutate(fleet.rentals[0]);
expect(validate("openfleet", fleet).ok).toBe(false);
});
it("rejects maximum units below the minimum", () => {
const fleet = fixture();
fleet.rentals[0].minimum_units = 25;
errorAt(fleet, "rentalUnits", "/rentals/0/maximum_units");
});
it("checks offer windows by instant, including timezone offsets", () => {
const fleet = fixture();
fleet.rentals[0].valid_from = "2026-09-13T12:00:00Z";
fleet.rentals[0].valid_until = "2026-09-13T14:00:00+02:00";
errorAt(fleet, "rentalPeriod", "/rentals/0/valid_until");
fleet.rentals[0].valid_until = "2026-09-13T13:00:00+02:00";
errorAt(fleet, "rentalPeriod", "/rentals/0/valid_until");
fleet.rentals[0].valid_until = "2026-09-13T15:00:00+02:00";
expect(validate("openfleet", fleet).ok).toBe(true);
});
});

View file

@ -0,0 +1,72 @@
import type { ErrorObject } from "ajv";
type MemberReference = { kind: "openagent" | "openswarm"; id: string };
// Called only after the JSON Schema has checked the shape. Keep the checks
// which need sibling values here; uniqueItems cannot enforce identity when
// two entries have different URLs or metadata.
type FleetReferences = {
members: MemberReference[];
rentals?: Array<{
id: string;
scope: { kind: "fleet" } | { kind: "members"; members: MemberReference[] };
minimum_units?: number;
maximum_units?: number;
valid_from?: string;
valid_until?: string;
}>;
};
function memberKey(member: MemberReference): string {
return JSON.stringify([member.kind, member.id]);
}
export function validateOpenFleetReferences(data: unknown): ErrorObject[] {
const fleet = data as FleetReferences;
const errors: ErrorObject[] = [];
function report(keyword: string, instancePath: string, message: string) {
errors.push({ keyword, instancePath, schemaPath: "#/openfleet-semantics", params: {}, message });
}
const members = new Set<string>();
fleet.members.forEach((member, i) => {
const key = memberKey(member);
if (members.has(key)) {
report("uniqueMember", `/members/${i}`, "duplicates an existing member kind and id");
}
members.add(key);
});
const rentals = new Set<string>();
fleet.rentals?.forEach((rental, i) => {
const path = `/rentals/${i}`;
if (rentals.has(rental.id)) {
report("uniqueRental", `${path}/id`, "duplicates an existing rental id");
}
rentals.add(rental.id);
if (rental.scope.kind === "members") {
const scopedMembers = new Set<string>();
rental.scope.members.forEach((member, j) => {
const key = memberKey(member);
const memberPath = `${path}/scope/members/${j}`;
if (!members.has(key)) {
report("memberReference", memberPath, "must reference a member of this fleet by kind and id");
}
if (scopedMembers.has(key)) {
report("uniqueMember", memberPath, "duplicates a member in this rental scope");
}
scopedMembers.add(key);
});
}
if (rental.maximum_units !== undefined && rental.maximum_units < (rental.minimum_units ?? 1)) {
report("rentalUnits", `${path}/maximum_units`, "must be at least minimum_units");
}
if (rental.valid_from && rental.valid_until && Date.parse(rental.valid_from) >= Date.parse(rental.valid_until)) {
report("rentalPeriod", `${path}/valid_until`, "must be later than valid_from");
}
});
return errors;
}

View file

@ -8,6 +8,7 @@
* could not load a single schema.
*/
import agentSchema from "@logicsrc/schemas/agent" with { type: "json" };
import openfleetSchema from "@logicsrc/schemas/openfleet" with { type: "json" };
import accountAuditEventSchema from "@logicsrc/schemas/account-audit-event" with { type: "json" };
import accountGrantSchema from "@logicsrc/schemas/account-grant" with { type: "json" };
import accountProviderSchema from "@logicsrc/schemas/account-provider" with { type: "json" };
@ -70,6 +71,7 @@ import credsAuditEventSchema from "@logicsrc/schemas/opencreds-audit-event" with
export const schemas = {
agent: agentSchema,
openfleet: openfleetSchema,
"account-audit-event": accountAuditEventSchema,
"account-grant": accountGrantSchema,
"account-provider": accountProviderSchema,