From 45c7629953e73f5d491643da94e99ff17de99f2a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 00:57:45 -0700 Subject: [PATCH] Ship OpenFleet contracts and the OpenWall draft proposal (#177) * feat: add OpenFleet membership and CoinPay rental contracts * Propose OpenWall broadcasts and direct messaging contracts * release: prepare OpenFleet and OpenWall public contracts * fix: use tested npm for compatible CLI installs --- README.md | 8 + .../openfleet-discovery.contract.test.ts | 30 ++ apps/logicsrc-web/public/install.sh | 16 +- apps/logicsrc-web/src/lib/specs.ts | 2 + docs/communication-accounts.md | 5 + docs/data-model.md | 1 + docs/openfleet.md | 158 +++++++++ docs/openswarm.md | 1 + docs/openwall.md | 260 ++++++++++++++ package-lock.json | 9 +- packages/cli/package.json | 2 +- packages/cli/src/install-npm.test.ts | 25 ++ packages/schemas/README.md | 9 +- .../schemas/fixtures/openfleet/mixed.json | 86 +++++ .../fixtures/openwall/announcement.json | 16 + .../schemas/fixtures/openwall/broadcast.json | 27 ++ .../schemas/fixtures/openwall/direct.json | 17 + .../fixtures/openwall/receipt-accepted.json | 13 + .../fixtures/openwall/receipt-retrying.json | 14 + packages/schemas/package.json | 8 +- .../schemas/logicsrc-openfleet.schema.json | 323 ++++++++++++++++++ .../logicsrc-openwall-message.schema.json | 246 +++++++++++++ .../logicsrc-openwall-receipt.schema.json | 229 +++++++++++++ packages/sdk/README.md | 34 ++ packages/sdk/package.json | 14 + packages/sdk/src/index.ts | 6 + packages/sdk/src/openfleet.test.ts | 35 ++ packages/sdk/src/openfleet.ts | 71 ++++ packages/validators/package.json | 6 +- packages/validators/src/index.ts | 5 + packages/validators/src/openfleet.test.ts | 141 ++++++++ packages/validators/src/openfleet.ts | 72 ++++ packages/validators/src/openwall.test.ts | 120 +++++++ packages/validators/src/schemas.ts | 7 + scripts/install-npm.cjs | 17 + 35 files changed, 2021 insertions(+), 12 deletions(-) create mode 100644 apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts create mode 100644 docs/openfleet.md create mode 100644 docs/openwall.md create mode 100644 packages/cli/src/install-npm.test.ts create mode 100644 packages/schemas/fixtures/openfleet/mixed.json create mode 100644 packages/schemas/fixtures/openwall/announcement.json create mode 100644 packages/schemas/fixtures/openwall/broadcast.json create mode 100644 packages/schemas/fixtures/openwall/direct.json create mode 100644 packages/schemas/fixtures/openwall/receipt-accepted.json create mode 100644 packages/schemas/fixtures/openwall/receipt-retrying.json create mode 100644 packages/schemas/schemas/logicsrc-openfleet.schema.json create mode 100644 packages/schemas/schemas/logicsrc-openwall-message.schema.json create mode 100644 packages/schemas/schemas/logicsrc-openwall-receipt.schema.json create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/src/openfleet.test.ts create mode 100644 packages/sdk/src/openfleet.ts create mode 100644 packages/validators/src/openfleet.test.ts create mode 100644 packages/validators/src/openfleet.ts create mode 100644 packages/validators/src/openwall.test.ts create mode 100644 scripts/install-npm.cjs diff --git a/README.md b/README.md index 987e005..a989608 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ npm --workspace @profullstack/logicsrc-mcp run build node packages/logicsrc-mcp/dist/index.js ``` +## OpenWall proposal + +[OpenWall](docs/openwall.md) proposes broadcasts to explicitly selected connections, followers, +following, or service users, plus direct messages through verified contact routes. The draft +defines recipient consent, private delivery outcomes, and a public/private AT Protocol mapping. +Message and receipt JSON Schemas and fixtures are included; delivery services and the proposed +`logicsrc wall` commands are not implemented. + ## OpenPRD [OpenPRD](docs/openprd.md) is a lightweight standard for product requirements documents: a repo diff --git a/apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts b/apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts new file mode 100644 index 0000000..17d915f --- /dev/null +++ b/apps/logicsrc-web/contract/openfleet-discovery.contract.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; +import { familyOfSpec } from "../src/lib/specs"; +import { listDocs, readDoc } from "../src/lib/docs"; +import { GET as llms } from "../src/app/llms.txt/route"; +import { GET as llmsFull } from "../src/app/llms-full.txt/route"; +import sitemap from "../src/app/sitemap"; + +// Spec discovery must work even when the optional blog database is offline. +vi.mock("../src/lib/supabase", () => ({ publicClient: () => { throw new Error("offline"); } })); + +describe.each([ + { slug: "openfleet", name: "OpenFleet", family: "process" }, + { slug: "openwall", name: "OpenWall", family: "people" } +])("$name public discovery", ({ slug, name, family }) => { + it("serves the specification through its family and docs index", () => { + expect(familyOfSpec(slug)?.slug).toBe(family); + expect(listDocs()).toContainEqual(expect.objectContaining({ slug, title: name })); + expect(readDoc(slug)).toContain("0.1 draft"); + }); + + it("includes a reachable docs URL and the full contract in the LLM feeds", async () => { + expect(await llms().text()).toMatch(new RegExp(`\\[${name}\\]\\(https://[^)]+/docs/${slug}\\)`)); + expect(await llmsFull().text()).toContain(readDoc(slug)!.trim()); + }); + + it("includes the docs route in the sitemap without a blog connection", async () => { + const entries = await sitemap(); + expect(entries.some((entry) => new URL(entry.url).pathname === `/docs/${slug}`)).toBe(true); + }); +}); diff --git a/apps/logicsrc-web/public/install.sh b/apps/logicsrc-web/public/install.sh index 939513d..34cec55 100755 --- a/apps/logicsrc-web/public/install.sh +++ b/apps/logicsrc-web/public/install.sh @@ -134,7 +134,21 @@ do_install() { ok "downloaded${short_sha:+ ($short_sha)}" info "installing dependencies (this can take a minute)…" - ( cd "$STAGE" && npm install --no-audit --no-fund --ignore-scripts ) >"$BUILD_LOG" 2>&1 \ + # Git dependency preparation can break under a newer system npm even with + # --ignore-scripts. Use the repo's tested npm where its Node engine permits + # it, while retaining the documented older-Node CLI installation path. + ( cd "$STAGE" && + npm_spec="" && + # Older tags predate the selector; preserve their install behavior too. + if [ -f scripts/install-npm.cjs ]; then + npm_spec="$(node scripts/install-npm.cjs)" || exit 1 + fi + if [ -n "$npm_spec" ]; then + npm exec --yes --package="$npm_spec" -- npm install --no-audit --no-fund --ignore-scripts + else + npm install --no-audit --no-fund --ignore-scripts + fi + ) >"$BUILD_LOG" 2>&1 \ || step_fail "npm install failed" info "building the CLI…" ( cd "$STAGE" && npm run build:cli ) >>"$BUILD_LOG" 2>&1 \ diff --git a/apps/logicsrc-web/src/lib/specs.ts b/apps/logicsrc-web/src/lib/specs.ts index 1bebc0f..e751126 100644 --- a/apps/logicsrc-web/src/lib/specs.ts +++ b/apps/logicsrc-web/src/lib/specs.ts @@ -51,6 +51,7 @@ export const FAMILIES: Family[] = [ "One Markdown file for a person or an agent, served from their own domain and linked from every platform that has a page for them. The profile carries the identity, the accounts and the topics; the sections carry what a platform needs to match on, so a job board, a booking site or a dating app reads the file instead of asking forty questions again.", specs: [ s("openprofile", "OpenProfile.md", "One Markdown file for who you are and where you are, people and agents alike", { status: "0.2" }), + s("openwall", "OpenWall", "Consent-based broadcasts and direct messages across contact networks, with an AT Protocol mapping", { landing: undefined, status: "draft" }), s("openresume", "OpenResume.md", "What you have done, in the same spirit, linked from the profile", { landing: undefined }), s("openjob", "OpenJob", "What the work is, so a candidate's agent and a job board agree", { landing: undefined }), s("openbroadcast", "OpenBroadcast", "The Broadcast section: the show a person hosts and who they are seeking"), @@ -103,6 +104,7 @@ export const FAMILIES: Family[] = [ specs: [ s("asdlc", "ASDLC", "The Agentic Software Development Lifecycle: nine phases, four conformance levels and the ratchet rule"), s("openprd", "OpenPRD", "A product requirement document an agent can execute and a person can read"), + s("openfleet", "OpenFleet", "Fleets of OpenAgent and OpenSwarm members, with metadata and rental rates through CoinPay", { landing: undefined, status: "draft" }), s("openswarm", "OpenSwarm", "Settlement and proof of work done under a peer-to-peer swarm"), s("openstream", "OpenStream", "A lossless byte-stream relay envelope, with benchmark reports per release", { landing: undefined }), s("openontology", "OpenOntology", "Five nouns for a shared ontology, with governance and interoperability notes"), diff --git a/docs/communication-accounts.md b/docs/communication-accounts.md index c2ccce1..5d84934 100644 --- a/docs/communication-accounts.md +++ b/docs/communication-accounts.md @@ -111,6 +111,11 @@ logicsrc email send --dry-run The initial scaffold exposes provider listings and dry-run placeholders. Live connect, sync, send, and publish flows require durable credential broker, approval queue, and audit persistence. +The [OpenWall proposal](/docs/openwall) builds on these account and grant boundaries +for broadcasts and direct messages across verified contact routes. Its receiving +policy, audience snapshots and AT Protocol mapping are draft contracts, not added +live provider capabilities. + ## Storage Deployable database migrations live under `supabase/migrations/`. diff --git a/docs/data-model.md b/docs/data-model.md index 4e3b7a3..ad7d910 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -51,6 +51,7 @@ Important relationships: - Task can have many submissions. - Agent can have many runs. - Agent run belongs to one task. +- [OpenFleet](/docs/openfleet) groups OpenAgent profile references and/or OpenSwarm file-key references, with fleet metadata and CoinPay rental offers scoped to the fleet or named members. - Reputation events belong to DIDs. - API keys belong to users, agents, or service accounts. - Permissions are scoped to resources. diff --git a/docs/openfleet.md b/docs/openfleet.md new file mode 100644 index 0000000..0ab785b --- /dev/null +++ b/docs/openfleet.md @@ -0,0 +1,158 @@ +# OpenFleet + +OpenFleet describes a fleet containing OpenAgent members, OpenSwarm members, or both, with fleet metadata and explicit rental offers paid through CoinPay. A fleet can publish an offer for the entire group or for selected members. Membership alone does not put a member up for rent. + +Status: 0.1 draft. The JSON Schema, offline validator, fixtures and SDK constructor are implemented in this repository. Member resolution, authorization, scheduling and payment execution belong to the application that consumes the descriptor. + +## What a member is + +Each member has a `kind`, stable `id` and HTTPS `url`. The pair `(kind, id)` is its identity within the fleet; changing its URL or metadata does not create a second member. A fleet MUST contain at least one member, and MUST NOT repeat that identity. Agent-only, swarm-only and mixed fleets are equally valid. A member may belong to more than one fleet. + +| Kind | Identity | Document at `url` | +| --- | --- | --- | +| `openagent` | The `did` of a LogicSRC agent profile, such as `analyst.coinpay` | A document conforming to the existing [`logicsrc.agent` schema](https://github.com/profullstack/logicsrc/blob/master/packages/schemas/schemas/logicsrc-agent.schema.json); its `did` MUST equal the member `id` | +| `openswarm` | `ed25519:` followed by the 64 lowercase hex characters of the swarm's file public key | The signed `ipfile.manifest` for that file, served by its publisher or an HTTP gateway; its `file` key MUST equal the member `id` | + +**OpenAgent binding:** this draft uses the existing LogicSRC agent profile as its OpenAgent representation. It does not introduce a second profile format. Agent identities, fleet `owner_did` and rental `payee_did` use the existing LogicSRC DID syntax (`name.coinpay`, for example), rather than a new identity namespace. + +**OpenSwarm binding:** [OpenSwarm](/docs/openswarm) retains its meaning as the paid, encrypted peer-to-peer distribution family. A member is an actual `ipfile` swarm identified by its stable file key, not a content-version hash, publisher key, hub, or AgentSwarm orchestration session. The manifest may change under that key. AgentSwarm's runtime can consume a fleet as application input, but a runtime session is not implicitly an `openswarm` member. The `url` is an HTTP view of the existing manifest, not a new OpenSwarm endpoint requirement. + +Fleet membership is a grouping reference. It neither copies the member document nor changes the member's operator, capabilities, permissions, pricing or settlement rules. Fleets cannot contain other fleets in version 0.1. Readers do not recursively expand swarm participants into fleet members. + +## Publication and discovery + +Serve a descriptor as `application/json` over HTTPS, normally at `/.well-known/openfleet.json`. `id` is the canonical HTTPS URL of this fleet descriptor; the well-known URL may serve the same document. An operator with several fleets serves each at its own canonical URL and links them from its site or directory listing. Each descriptor still represents one fleet. + +Directories fetch the operator's document, retain its canonical ID and source URL, and display `updated_at` and any offer validity window. The specification is listed in LogicSRC's Agents and process family, documentation index, sitemap and LLM discovery files. + +## Example + +The URLs and identities below are illustrative, not a live checkout or a claim of control over a running swarm. + +```json +{ + "type": "logicsrc.openfleet", + "version": "0.1", + "id": "https://example.com/fleets/research", + "name": "Research fleet", + "owner_did": "operator.coinpay", + "description": "An analyst and an encrypted reference-data swarm", + "availability": "available", + "metadata": { "region": "eu-west" }, + "members": [ + { + "kind": "openagent", + "id": "analyst.coinpay", + "url": "https://example.com/agents/analyst.json", + "role": "analysis" + }, + { + "kind": "openswarm", + "id": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "url": "https://example.com/swarms/references/manifest.json", + "role": "reference-data" + } + ], + "rentals": [ + { + "id": "hourly", + "scope": { "kind": "fleet" }, + "rate": { "amount": "25.000000", "currency": "USD", "unit": "hour" }, + "minimum_units": 1, + "terms_url": "https://example.com/rental-terms", + "payment": { + "provider": "coinpay", + "payee_did": "operator.coinpay", + "checkout_url": "https://example.com/rentals/research" + } + } + ] +} +``` + +## Fleet and member metadata + +| Field | Rule | +| --- | --- | +| `type`, `version` | Required constants `logicsrc.openfleet` and `0.1` | +| `id`, `name`, `owner_did` | Required canonical descriptor URL, nonempty display name and responsible operator's DID | +| `members` | Required nonempty array of typed member references | +| `description` | Optional human-readable description | +| `updated_at` | Optional RFC 3339 instant when the descriptor was last revised | +| `availability` | Optional `available`, `busy`, `offline` or `unknown`; absent means unknown, and an available listing is not a reservation | +| `tags`, `capabilities` | Optional arrays of distinct nonempty strings; fleet-level claims that do not override a member's permissions | +| `metadata` | Optional JSON object for additional fleet data, such as region, support information or hardware inventory | +| `rentals` | Optional array of explicit rental offers; absent or empty means no rental offer is advertised | + +Members may also carry `name`, `role` and a JSON-object `metadata` field. Unknown fields outside `metadata` are rejected in 0.1 to catch misspelled contract fields. Metadata MUST contain only information intended for publication, never account credentials or payment secrets. Readers preserve metadata, but it cannot override normative fields. + +## Rental offers + +Each rental requires a unique fleet-local `id`, a `scope`, a `rate` and a `payment` block. `name`, `terms_url`, `metadata`, unit bounds and validity times are optional. + +| Field | Meaning | +| --- | --- | +| `scope: { "kind": "fleet" }` | One rate for the whole fleet as listed in the accepted quote; never multiplied by the member count | +| `scope: { "kind": "members", "members": [...] }` | One rate for exactly those `(kind, id)` references, including either kind or both; the list must be nonempty, contain no duplicates and reference existing fleet members | +| `rate.amount` | Non-negative decimal **string** with exactly six fractional digits, such as `"0.002500"`; scientific notation, negative amounts and JSON numbers are invalid | +| `rate.currency` | `USD` in version 0.1, following OpenSwarm's amount convention | +| `rate.unit` | `hour` (3,600 seconds), `day` (86,400 seconds), `month` (30 days), or `task` (one agreed deliverable) | +| `minimum_units`, `maximum_units` | Positive safe integers; minimum defaults to 1, an absent maximum is unstated, and maximum cannot be below minimum | +| `valid_from`, `valid_until` | Optional RFC 3339 bounds on when an offer may be accepted; start is inclusive, end is exclusive, and end must be later than start when both are present | +| `terms_url` | Optional HTTPS URL explaining deliverables, availability, billing, cancellation and any separate usage charges | + +An individual-member offer therefore uses a scope such as: + +```json +{ + "kind": "members", + "members": [{ "kind": "openagent", "id": "analyst.coinpay" }] +} +``` + +The whole-fleet and member offers are alternatives, not charges automatically added together. The descriptor makes no implicit per-member rate inheritance. Removing a member requires updating or removing offers that reference it. If membership or terms change, the consumer MUST obtain a new quote before acceptance; an existing accepted rental keeps its agreed member set and terms. + +Amounts follow [OpenSwarm core's six-decimal USD convention](https://github.com/profullstack/logicsrc/blob/master/docs/openswarm/spec.md). Consumers calculate with integer millionths of a dollar or a decimal library, never JavaScript `Number`. For example, `"0.002500"` becomes `2500n` millionths; 3 agreed billing units cost `7500n`, or `"0.007500"`. `"0.000000"` explicitly states a zero rental rate. Missing rentals never imply free use. + +Version 0.1 quotes whole billing units. For timed rentals the accepted quote MUST state a whole number of units and the covered period; partial use does not silently introduce a rounding rule. A task offer's accepted quote MUST specify its deliverable. Taxes, collateral, cancellation, additional usage and currency conversion are agreed at checkout; clients MUST NOT infer them from this rate. The offer's validity window concerns acceptance, not the period of service. Validation checks that a window is ordered, not that an offer is currently available. + +## CoinPay settlement + +Every rental advertises `payment.provider: "coinpay"`, a `payee_did` and an HTTPS `checkout_url` published by the merchant. The payee can differ from the fleet owner when the owner authorizes that settlement recipient. The checkout URL is an entry point for agreeing to this offer and obtaining a CoinPay-backed checkout or escrow; OpenFleet specifies no new CoinPay API path, rail, wallet address format or payment-proof format. + +The consuming application confirms the fleet ID, rental ID, current member set, exact amount, units, payee and terms with the merchant before creating any payment. A checkout response, signed agreement or application record binds those values; the application verifies payment using its configured CoinPay integration. Fetching or validating a descriptor never initiates a payment, and following its URL does not itself prove that CoinPay backs a merchant's claim. + +For an OpenSwarm member, the existing `ippay` grants, passes, vouchers and proof rules still govern actual access and delivery. A fleet rental does not replace them, mint a key grant or grant access to an agent's connected accounts. Terms must state whether underlying swarm usage is included or charged separately. Fleet-level payment does not authorize automatic fan-out payouts to members. + +## Validation and SDK + +The schema is exported as `@logicsrc/schemas/openfleet`. Its agent identity definition references `@logicsrc/schemas/agent`, so standalone JSON Schema consumers must register that schema as well. `@logicsrc/validators` registers both automatically and additionally checks member and rental uniqueness, dangling rental references, unit bounds and validity ordering. + +```ts +import { createOpenFleet } from "@logicsrc/sdk"; +import { validate } from "@logicsrc/validators"; + +const fleet = createOpenFleet({ + id: "https://example.com/fleets/analyst", + name: "Analyst fleet", + owner_did: "operator.coinpay", + members: [{ + kind: "openagent", + id: "analyst.coinpay", + url: "https://example.com/agents/analyst.json" + }] +}); +const result = validate("openfleet", fleet); +if (!result.ok) throw new Error(JSON.stringify(result.errors)); +``` + +`createOpenFleet` adds the type and version; it is a constructor, not a runtime validator. The SDK exports `OpenFleet`, `OpenFleetMember`, `OpenFleetMemberReference`, `OpenFleetRental` and `OpenFleetRentalScope` types. The validator does not mutate input or fetch URLs, resolve DIDs, verify manifests, establish operator authority or execute payments. Consumers MUST resolve and verify the referenced identities and the operator's authority before scheduling work or accepting a rental. Merely listing somebody else's agent or swarm does not confer authority to rent it. + +Run the supplied mixed-fleet fixture through the CLI after building the validators: + +```sh +npm --workspace @logicsrc/validators run build +node packages/validators/dist/cli.js openfleet packages/schemas/fixtures/openfleet/mixed.json +``` + +The complete [fixture](https://github.com/profullstack/logicsrc/blob/master/packages/schemas/fixtures/openfleet/mixed.json) includes both a fleet-wide hourly offer and an agent-only per-task offer. The [schema](https://github.com/profullstack/logicsrc/blob/master/packages/schemas/schemas/logicsrc-openfleet.schema.json) plus the semantic checks in `@logicsrc/validators` are the executable 0.1 contract. diff --git a/docs/openswarm.md b/docs/openswarm.md index e314125..7828ee2 100644 --- a/docs/openswarm.md +++ b/docs/openswarm.md @@ -43,6 +43,7 @@ a different product. The member protocols keep their `ip` names. Supporting documents: +- [OpenFleet](./openfleet.md): group `ipfile` swarms and OpenAgent profiles under one descriptor, with fleet metadata and CoinPay rental offers; each swarm keeps its existing identity and settlement rules. - c0mpute.com integration and use cases: [`c0mpute.md`](./openswarm/c0mpute.md) - Proposed `ip` CLI: [`cli.md`](./openswarm/cli.md) - Conformance: [`conformance.md`](./openswarm/conformance.md) diff --git a/docs/openwall.md b/docs/openwall.md new file mode 100644 index 0000000..0e2d021 --- /dev/null +++ b/docs/openwall.md @@ -0,0 +1,260 @@ +# OpenWall + +OpenWall proposes one portable message contract for reaching a person's or agent's connections, followers, following, or a service's users, with direct messages through the same contact routes. + +Status: **0.1 draft proposal**, September 13, 2026. This change supplies a specification, JSON Schemas, and validation fixtures. The commands, delivery service, OpenContacts/OpenSocial adapters, and AT Protocol extension below are proposed; none is a shipped OpenWall runtime or an accepted AT Protocol standard. + +Slug: `openwall` + +## The experience + +Unix `wall` broadcasts a notice to logged-in terminals. `write username` sends to a particular user; `mesg` controls whether a terminal accepts messages. OpenWall carries that small interface across contact networks. Receiving remains the recipient's choice. + +These are proposed commands under the existing `logicsrc` CLI. `wall` prepares a private broadcast; `write` prepares a private direct message; `mesg` edits the current identity's receiving settings. Preparing a message only resolves and previews its audience. `send` dispatches the saved plan under the account's existing authorization policy. + +```bash +# Resolve a service's users and preview the exact audience and routes. +logicsrc wall --from ops.example --source https://dev.example/contacts \ + --to users --scope https://dev.example/workspaces/main \ + --topic maintenance --expires-in 10m --save notice.json <<'EOF' +SERVER NOTICE +Maintenance starts in 10 minutes. +Please save your work and pause running jobs. +EOF +logicsrc wall send notice.json + +# Union of explicit relationship groups, deduplicated before delivery. +logicsrc wall --from ada.example --source https://social.example/graph \ + --to connections,followers,following --topic updates \ + --expires-in 1d --text 'The new release is ready.' --save release.json + +# Resolve a verified contact alias to one stable identity. +logicsrc wall write ada.example --text 'Ready when you are.' \ + --expires-in 1h --save direct.json +logicsrc wall send direct.json + +# Receiving policy: these change your settings, never somebody else's. +logicsrc wall mesg off +logicsrc wall mesg on --allow connections --topic maintenance +logicsrc wall mesg show + +# Public publishing must be selected explicitly. +logicsrc wall announce --public --text 'Version 1.2 is available.' \ + --expires-in 7d --save announcement.json +``` + +A preview shows the sender, subject text, visibility, sources and snapshot time, candidate/eligible/excluded counts, unavailable sources, selected routes, expiry, and any provider limits. A source failure produces an incomplete plan that cannot be sent; selecting a smaller audience produces a new plan. An empty eligible set is a no-op with a report. No implicit `all` audience exists. A saved plan is private and contains no credentials. CLI output MUST escape terminal control characters from message text and account labels. + +## How the pieces fit + +| Piece | Responsibility | Existing status in this repository | +| --- | --- | --- | +| [OpenProfile](/docs/openprofile) | Profile and external account claims, including verification | Existing specification | +| Communication Accounts | Connected identities, delegated grants, credential broker and audited provider calls | Existing scaffold in `docs/communication-accounts.md`, `packages/account-core`, and `plugins/social-accounts`; provider metadata does not establish live delivery support | +| OpenContacts | Private address book, verified identity links, group membership and preferred routes | Proposed integration role; no standalone OpenContacts specification in this checkout | +| OpenSocial | Provider graph and messaging adapter boundary | Proposed integration role; no standalone OpenSocial specification in this checkout; this name does not assert compatibility with another project's similarly named API | +| OpenWall | Message, audience selection, receiving policy and delivery outcomes | This proposal | + +OpenWall can start with an address-book adapter and a social-account adapter without waiting for either proposed sibling spec. Their minimum interfaces are defined here so the names do not conceal a dependency on an undefined protocol. The existing social-post contract describes publishing; it does not become a private-message envelope. + +## Identity and adapters + +An **identity** is a stable DID or an HTTPS identity URI controlled by its issuer. A handle or username is user input and a display label; the sender resolves it before freezing a plan. A contact row ID is scoped to its address book and cannot be used as a global account ID. A provider account and the person owning it are distinct identities until an authenticated linking flow proves their relationship. A profile account link alone is a claim. + +For AT Protocol accounts, use the DID as the canonical account identity, verify a handle against its DID document in both directions, and resolve the PDS from that document. A handle change or PDS migration does not change the DID. [AT Protocol DID specification](https://atproto.com/specs/did). + +The proposed adapters expose these operations; names describe interfaces, not existing endpoints: + +| Operation | Required result | +| --- | --- | +| `resolve(input, source)` | Canonical identity, verified account links, provenance and observation time; ambiguous or unverified aliases fail resolution | +| `listRelationships(actor, group, scope, cursor)` | Identity page, cursor, source, observation time, and completeness/version information when supported | +| `getRoutes(identity)` | Verified recipient-authorized routes, account identity, transport, size limits, privacy, idempotency and receipt capabilities | +| `checkReceivePolicy(sender, recipient, topic, mode)` | `allow`, `deny`, or `unknown`, with policy revision and expiry; local block reasons stay private | +| `deliver(route, delivery)` | Authenticated transport acceptance, rejection or unknown outcome; a provider message ID when available | +| `reconcile(route, deliveryId)` | A known previous outcome, or `unknown`; optional only when the route cannot support reconciliation | + +A route is selected from a verified recipient association or an explicitly configured trusted service. A sender-supplied message cannot override its destination URL. Clients MUST authenticate the route's operator, validate endpoint ownership, restrict redirects and internal-network requests according to deployment policy, and use the credential broker for account authorization. Reading a graph does not grant permission to send from that account. + +Delegated agents act under a grant bound to the sending identity, permitted audience/scope, topic and volume. The coordinator records the acting principal and grant in a private audit record. `sender` in JSON is not evidence of authority. For a future native HTTP adapter, discovery, scoped authentication, and signed service-to-service deliveries need their own specified profile before interoperable deployment; this proposal does not invent a universally discoverable inbox. + +## Audiences and snapshots + +A message has exactly one audience form: + +| Audience | Meaning | +| --- | --- | +| `relationships` / `connections` | Explicit accepted connections in the named source, from the named actor's perspective; a social adapter may advertise mutual follows as its connection rule | +| `relationships` / `followers` | Identities that follow the actor | +| `relationships` / `following` | Identities the actor follows | +| `relationships` / `users` | Active members of the exact service/workspace `scope`; requires authority to enumerate and message that scope | +| `direct` | Exactly one resolved recipient identity | +| `public` | A public announcement; there is no confidential recipient list and no promise of individual delivery | + +Relationship selectors carry `source`, `actor`, and `groups`, with `scope` required for `users`. `actor` normally equals the sender; acting for another account requires a corresponding grant. Multiple selectors mean union. Exclusions are applied after the union. Duplicate identities are removed, self-delivery is excluded unless directly addressed, then receiving policy and route capability are checked. Unknown groups are rejected; there is no interpretation of `users` as everyone on the internet or all customers of another service. + +1. Resolve every selector, exhaust pagination, and record observation times, source revisions when available, and errors. Providers without consistent snapshots produce an explicitly labeled observation interval, not a claim of an atomic graph snapshot. +2. Freeze the candidate identities and verified account-link evidence in a private audience snapshot. Route selection sends at most once to a verified linked identity even if it occurs in several groups or networks. Without verified links, distinct accounts remain distinct and possible duplicates are shown in the preview. +3. Bind the saved plan to the exact message, snapshot and authorized routes. Store a digest over a deterministic serialization chosen and documented by the implementation; this local plan digest is not a new cross-service signature format. Altering content or adding recipients invalidates authorization for that plan. +4. Before dispatch and each retry, recheck exclusions, current membership, blocks, receiving policy, sender grants and expiry. Revoked membership or consent removes a recipient; membership uncertainty defers that delivery. New followers or users after the snapshot are never added. Refreshing or enlarging the audience creates a new plan. + +Snapshots and delivery ledgers MUST stay private to the authorized sender/coordinator. Deliveries contain only the individual recipient; no address-book labels, group names, other recipients, or graph snapshots leave the coordinator. A broadcast creates individual deliveries, never a group chat. Replies go back to the sender unless the recipient separately chooses another destination. + +## Receiving policy: the portable `mesg` + +The baseline is `enabled: false`. An identity enables particular sender relationships and topics, optionally adds a per-sender allowlist, and may disable broadcasts independently of direct messages. An absent topic grant denies that topic; implementations may offer an explicit wildcard. The following proposed private policy illustrates the behavior (it is not an AT repository record): + +```json +{ + "enabled": true, + "allowFrom": ["connections"], + "topics": ["maintenance"], + "broadcasts": true, + "direct": true, + "readReceipts": false +} +``` + +Here relationships are from the **recipient's** perspective. `following` would mean senders the recipient follows. Following someone, importing their contact details, or belonging to the same service does not by itself opt either party into OpenWall broadcasts. An explicit service-notices subscription can authorize `users` delivery for its named scope and topics. + +Effective permission is the intersection of the sender's grant, OpenWall receiving consent, provider restrictions, and service policy. A deny at any layer wins; `unknown` cannot be treated as consent. A block overrides an allowlist. A recipient can revoke consent or unsubscribe from a topic immediately, including for messages already queued. Quiet hours may delay an eligible notice until the next allowed window if it has not expired. A mute suppresses notifications; it is distinct from refusing storage/delivery. + +A bridge to a recipient without OpenWall software needs a verifiable prior subscription or equivalent recipient-managed policy; a provider's general willingness to receive DMs is insufficient consent to bulk broadcasts. Rate caps apply per sender, recipient, topic and service. The sender sees `not-permitted`, not whether the recipient blocked them or has a particular private contact relationship. Read receipts require recipient opt-in. There is no remote equivalent of `sudo` that bypasses receiving policy. + +## Message contract + +The draft JSON Schema is `packages/schemas/schemas/logicsrc-openwall-message.schema.json`, exported as `@logicsrc/schemas/openwall-message`. It defines a **private sender-side job document**, not an object to publish verbatim in an AT repository or forward to every recipient. Schema identifiers are canonical names; this proposal does not deploy them to a schema host. + +```json +{ + "openwall": "0.1-draft", + "id": "urn:uuid:94d908df-2876-48f1-bec8-c931f5c847d4", + "sender": "did:web:ops.example", + "createdAt": "2026-09-13T12:00:00Z", + "expiresAt": "2026-09-13T12:10:00Z", + "visibility": "private", + "topic": "maintenance", + "audience": { + "kind": "relationships", + "selectors": [{ + "source": "https://dev.example/contacts", + "actor": "did:web:ops.example", + "groups": ["users"], + "scope": "https://dev.example/workspaces/main" + }], + "exclude": [] + }, + "content": { + "mediaType": "text/plain", + "text": "SERVER NOTICE\nMaintenance starts in 10 minutes.\nPlease save your work and pause running jobs." + } +} +``` + +`id` is a new UUID URN, stable across retries. `createdAt`, optional `notBefore`, and required `expiresAt` are RFC 3339 timestamps. `expiresAt` MUST be later than both creation and scheduling, with a maximum 30-day lifetime in this draft. An omitted `notBefore` means dispatch as soon as the authorized plan is ready. Receivers reject expired deliveries; scheduling, freshness and clock comparison are semantic requirements beyond JSON Schema validation. + +`content.text` is plain text, limited to 4,000 Unicode code points and 16,000 UTF-8 bytes before transport-specific limits. Do not silently truncate or split it into many messages: an incompatible route reports `unsupported`. HTML, attachments and automatic execution of embedded instructions are outside this draft. Human or agent recipients receive text as data. + +`visibility: private` permits only `relationships` or `direct`. A direct audience is `{ "kind": "direct", "recipient": "did:web:ada.example" }`. `visibility: public` requires `{ "kind": "public" }`. Private means access-controlled delivery to a recipient and its service operators; it does **not** promise end-to-end encryption. Routes that offer encryption must name their separately specified security profile. Never downgrade a private message to a public post on failure. + +For a native transport, derive a recipient delivery containing only the message ID, sender, individual recipient, content, topic, scheduling/expiry fields, and authenticated delivery ID. Identity linkage, audience selectors and authorization tokens do not belong in that payload. A legacy provider may only accept text; its adapter keeps the correlation and consent ledger privately and must report the capabilities it cannot preserve. + +## Delivery, receipts and failure + +The coordinator persists the plan and per-recipient ledger before calling a provider. Its idempotency key is the tuple `(sender, message.id, canonical recipient)` and the payload binding is immutable. Reusing that key with different content or a different plan is a conflict. The delivery ID is a coordinator-assigned UUID URN, stable for all attempts to that recipient and bound in the immutable ledger to the sender/message/recipient tuple. Keep deduplication state through `expiresAt` plus at least 24 hours; late requests outside that window are rejected by expiry checks. + +Route preference belongs to the recipient: for example a native inbox, then an explicitly authorized Bluesky chat account. A provider credential is never a routing preference. Fallback is allowed only within previously authorized routes after a definitive rejection establishing that nothing was accepted. A timeout after sending is **unknown**, and must not trigger a second route that might duplicate the message. + +The receipt schema, `@logicsrc/schemas/openwall-receipt`, describes a private coordinator event about one recipient. Each event has a UUID `id`, `deliveryId`, `messageId`, `recipient`, increasing `sequence` per `deliveryId`, `state`, `at`, and `attempt`. `attempt` is zero before the first network send. Authentication to the coordinator's receipt API is required; a receipt file alone proves nothing. The coordinator MUST verify `deliveryId`, `messageId` and `recipient` against the immutable sender-bound ledger. Provider callbacks must be authenticated and correlated to that delivery and its authorized route. Duplicate or older event IDs/sequences cannot regress state. + +| State | Meaning and permitted next steps | +| --- | --- | +| `queued` | Persisted locally; may become `sending`, `expired`, `cancelled`, or `failed` | +| `sending` | A recorded provider attempt; becomes `accepted`, `retrying`, `unknown`, or `failed` | +| `retrying` | Definitively unaccepted temporary failure; carries `nextAttemptAt` and a reason; becomes `sending`, `expired`, `cancelled`, or `failed` | +| `accepted` | Provider acknowledged storage/queueing; does not assert delivery or reading; may become `delivered`, `read`, or `failed` on a definitive downstream failure | +| `delivered` | Recipient service explicitly confirms inbox delivery; may become `read` | +| `read` | Recipient consent and transport evidence confirm reading; terminal | +| `unknown` | An attempt may have succeeded; reconcile to `accepted`, `delivered`, `read`, or a definitive failure; retry only when non-acceptance or transport idempotency is established | +| `failed` | Permanent rejection or exhausted retry budget; terminal for automatic delivery | +| `expired` | Deadline passed before another delivery attempt; terminal | +| `cancelled` | Sender stopped pending delivery; terminal | + +If reconciliation proves non-acceptance while the message remains live, `unknown` may become `retrying`; otherwise it remains unresolved even after the deadline. No retry is allowed after expiry. A valid late receipt may resolve an unknown historical outcome. A provider may supply stronger evidence immediately, so a `sending` event may advance directly to `delivered` or `read`. Store the evidence rather than manufacturing intermediate acknowledgements. + +Temporary network failures known to precede acceptance, `429`, and recoverable `5xx` responses use bounded exponential backoff with jitter, respecting `Retry-After` and expiry. Start at one second, cap at five minutes, and allow at most eight attempts in this draft. Authorization, policy and payload errors are permanent until an authorized new plan addresses them. An expired token may be refreshed once through the credential broker; refreshed credentials never enlarge a grant. After ambiguous acceptance, an adapter lacking provider idempotency/reconciliation reports `unknown` and stops automatic sending. Exactly-once network delivery is not promised. + +```json +{ + "openwall": "0.1-draft", + "id": "urn:uuid:963f9109-803f-4f0c-8506-10c70ec47ee6", + "messageId": "urn:uuid:94d908df-2876-48f1-bec8-c931f5c847d4", + "deliveryId": "urn:uuid:db925d64-4e1a-4e5a-83c1-e37b68aa5eef", + "recipient": "did:web:ada.example", + "sequence": 3, + "state": "accepted", + "at": "2026-09-13T12:00:03Z", + "attempt": 1, + "route": "https://chat.example", + "providerMessageId": "provider-msg-123" +} +``` + +The sender sees counts by state and an explicit incomplete/unknown count. Unsupported delivery/read receipts stay unsupported; an HTTP success, public post, notification count, or relay cursor is not a read receipt. Receipt details and logs MUST not expose recipient policies or message bodies to unauthorized observers. Implementations document retention and permit deletion of content while keeping minimal deduplication tombstones. + +Cancellation stops pending sends and retries; it cannot recall an already accepted provider message. Expiry bounds attempts and future display by cooperating native clients, not retention on third-party services. Deletion of a public announcement cannot guarantee removal of cached or replicated copies. Scheduled messages are not made public before `notBefore`. + +## AT Protocol mapping + +This is an application proposal on AT Protocol, not a request to add a broadcast primitive to its core. Reuse DIDs and graph data where applicable; specify OpenWall-specific records and service behavior under a domain-controlled Lexicon namespace. AT Protocol supports application record and RPC schemas through Lexicon/NSIDs. [Lexicon guide](https://atproto.com/guides/lexicon). + +### Graph discovery + +Bluesky exposes `app.bsky.graph.getFollowers` and `app.bsky.graph.getFollows` with pagination. An adapter can union/intersect their DID results, using the snapshot rules above; it cannot infer private address-book connections or a service's users from those APIs. API pages are observations of an AppView, not a globally consistent graph transaction. [Official getFollowers Lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/graph/getFollowers.json), [official getFollows Lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/graph/getFollows.json). + +### Public announcements + +AT repositories and their replicated records are public. An audience field in a public record cannot make it followers-only or private. OpenWall MUST NOT write private message bodies, recipient lists, contact graphs, receipts or receiving preferences into a public repository or relay stream. [Understanding AT Protocol](https://atproto.com/guides/understanding-atproto). + +Propose `com.logicsrc.openwall.announcement` as a domain-controlled record containing only `messageId`, `createdAt`, optional `notBefore`, `expiresAt`, `topic` and plain `text`. All its fields are public. The namespace is provisional, not registered or published by this change, and `com.logicsrc` requires the domain owner's publication authority. Before adoption, publish and validate an actual Lexicon and establish its versioning policy. JSON Schema is not a Lexicon and cannot be sent as one. + +```json +{ + "$type": "com.logicsrc.openwall.announcement", + "messageId": "urn:uuid:5bb154dd-edb3-47b7-8fc6-4cb65a19a9f8", + "createdAt": "2026-09-13T12:00:00Z", + "expiresAt": "2026-09-20T12:00:00Z", + "topic": "releases", + "text": "Version 1.2 is available." +} +``` + +An OpenWall AppView may index such records and offer recipient-managed topic subscriptions. A record's AT URI and CID identify publication and its version, not individual delivery. Feed inclusion and notifications need application support; a new Lexicon does not automatically appear in Bluesky feeds or notify every follower. Projecting to `app.bsky.feed.post` is a separately authorized public-post adapter, with that record's own limits and no confidentiality claim. + +### Private delivery through Bluesky chat + +Bluesky's `chat.bsky.*` APIs are hosted by its separate chat service and can be reached through authenticated PDS proxying. Do not derive a private inbox from repository support or assume other AT apps implement Bluesky chat. [Official Bluesky API directory](https://github.com/bluesky-social/bsky-docs/blob/main/docs/advanced-guides/api-directory.mdx). + +For each independently consented recipient, obtain the direct conversation using `chat.bsky.convo.getConvoForMembers`, then use `chat.bsky.convo.sendMessage`. The former may create a conversation despite being a query, so previews MUST NOT call it. Keep one conversation per recipient, even though current chat Lexicons also describe group conversations. The send procedure requires `convoId` and a `messageInput`; its returned message is evidence of provider acceptance. [Official conversation lookup Lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/chat/bsky/convo/getConvoForMembers.json), [official sendMessage Lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/chat/bsky/convo/sendMessage.json). + +Honor the recipient's `chat.bsky.actor.declaration.allowIncoming` (`all`, `none`, `following`) plus OpenWall's broadcast consent. This chat declaration is a public provider-specific record; it does not serialize the private OpenWall policy above. Provider permission errors are authoritative and MUST NOT cause a public mention or another-account workaround. [Official chat declaration Lexicon](https://github.com/bluesky-social/atproto/blob/main/lexicons/chat/bsky/actor/declaration.json). + +Validate the current provider text limits and actual authentication permissions during adapter implementation. Do not assume the OpenWall message ID can be used as a provider idempotency key: the reviewed `sendMessage` input and referenced `messageInput` do not specify an OpenWall-style key. Keep the private ledger and stop on ambiguous acceptance unless reconciliation establishes the result. Do not claim private chat federation, end-to-end encryption, recipient-device delivery or read receipts based merely on using AT identities. [Official chat message definitions](https://github.com/bluesky-social/atproto/blob/main/lexicons/chat/bsky/convo/defs.json). + +The long-term OpenWall native service profile could use Lexicon RPCs with AT identity authentication while storing private messages off-repository. Its inbox discovery, authorization, encryption, portability and event-stream contract require a separate reviewed specification and two interoperating implementations. This draft does not claim that profile exists today. + +## Conformance and next implementation + +The included schemas validate message/receipt structure, closed audience alternatives, mandatory `users` scope, public/private separation, timestamps, and receipt reason/retry fields. They cannot verify identity ownership, grants, consent, time ordering, UTF-8 byte limits, graph completeness, deduplication, transport security or state transitions. A schema-valid document alone is not permission to deliver it. + +Before calling a delivery implementation conformant, its integration suite MUST demonstrate: + +1. Union/deduplication across overlapping groups; pagination failure blocks an incomplete plan; `users` requires a named scope and sender authority. +2. A follow or address-book import without consent results in no delivery; a later block, unsubscribe or membership removal suppresses a queued send. +3. An unverified account link cannot redirect a message; a changed AT handle preserves the resolved DID. +4. Recipient payloads contain no other recipient or selector, and private failure cannot create a public post or group conversation. +5. Replaying a message uses its existing ledger; conflicting content is rejected; a timeout after provider acceptance cannot trigger blind resend/fallback. +6. Retry limits, `Retry-After`, cancellation and expiry are respected, including restart recovery and unknown outcomes. +7. Duplicate/out-of-order receipts do not regress state; `accepted` does not display as `read`; public publication does not fabricate per-follower receipts. + +First implement local plan/preview and receiving-policy storage, then a fake inbox adapter proving these behaviors, then an explicitly consented Bluesky chat bridge. Public announcement indexing can evolve separately. A native cross-service transport and a formal OpenContacts graph/route contract follow once two implementers agree on the wire details. No remote messages are sent by this proposal or its fixtures. diff --git a/package-lock.json b/package-lock.json index 1908a61..e6ab6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8947,7 +8947,7 @@ }, "packages/cli": { "name": "@logicsrc/cli", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@fission-ai/openspec": "^1.13.0", "@logicsrc/account-core": "file:../account-core", @@ -9063,12 +9063,13 @@ }, "packages/schemas": { "name": "@logicsrc/schemas", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT" }, "packages/sdk": { "name": "@logicsrc/sdk", "version": "0.1.0", + "license": "MIT", "devDependencies": { "vitest": "^4.0.8" } @@ -9087,10 +9088,10 @@ }, "packages/validators": { "name": "@logicsrc/validators", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { - "@logicsrc/schemas": "^0.1.0", + "@logicsrc/schemas": "^0.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "yaml": "^2.8.1" diff --git a/packages/cli/package.json b/packages/cli/package.json index e0820b3..3484500 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/cli", - "version": "0.2.0", + "version": "0.2.1", "description": "LogicSRC CLI: every LogicSRC standard and tool as one command.", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/src/install-npm.test.ts b/packages/cli/src/install-npm.test.ts new file mode 100644 index 0000000..3446f87 --- /dev/null +++ b/packages/cli/src/install-npm.test.ts @@ -0,0 +1,25 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { selectNpm } = require("../../../scripts/install-npm.cjs") as { + selectNpm: (nodeVersion: string, packageManager: string) => string | null; +}; + +describe("installer npm compatibility", () => { + it.each(["18.20.8", "20.16.0", "21.7.3", "22.8.0"])("preserves the host npm on Node %s", (version) => { + expect(selectNpm(version, "npm@11.11.0")).toBeNull(); + }); + + it.each(["20.17.0", "20.19.0", "22.9.0", "23.0.0", "24.18.1", "25.0.0"])( + "selects the tested npm on Node %s", (version) => { + expect(selectNpm(version, "npm@11.11.0")).toBe("npm@11.11.0"); + } + ); + + it("does not assume compatibility for a different package manager or npm major", () => { + expect(selectNpm("24.18.1", "pnpm@11.11.0")).toBeNull(); + expect(selectNpm("24.18.1", "npm@12.0.0")).toBeNull(); + expect(selectNpm("unknown", "npm@11.11.0")).toBeNull(); + }); +}); diff --git a/packages/schemas/README.md b/packages/schemas/README.md index ee9a606..2ea1af9 100644 --- a/packages/schemas/README.md +++ b/packages/schemas/README.md @@ -3,7 +3,7 @@ Canonical JSON Schemas (draft 2020-12) for the LogicSRC open coordination standards, maintained by Profullstack, Inc. -Two schema families ship from this package: +Schema families include: - **LogicSRC core** — `logicsrc-*.schema.json`: tasks, agents, runs, events, plugins, connected accounts, email messages, social posts. @@ -11,6 +11,13 @@ Two schema families ship from this package: contract for CLI tools and AI agents (ads, placements, requests, responses, impressions, clicks, campaigns). See `docs/agentad.md` in the repo; [cl1s.tech](https://github.com/profullstack/cl1s.tech) is the reference network. +- **OpenWall draft** — `openwall-message` and `openwall-receipt` exports define + sender-side audience jobs and private delivery events. Fixtures are in + `fixtures/openwall`. See `docs/openwall.md` for consent, routing, and runtime + requirements beyond structural validation; no OpenWall delivery runtime ships. +- **OpenFleet draft** — the `openfleet` export describes fleets of OpenAgent + profiles and OpenSwarm file-key references, with metadata and CoinPay rental + offers. `@logicsrc/validators` also checks membership and rental references. ## Install diff --git a/packages/schemas/fixtures/openfleet/mixed.json b/packages/schemas/fixtures/openfleet/mixed.json new file mode 100644 index 0000000..aa6cce5 --- /dev/null +++ b/packages/schemas/fixtures/openfleet/mixed.json @@ -0,0 +1,86 @@ +{ + "type": "logicsrc.openfleet", + "version": "0.1", + "id": "https://example.com/fleets/research", + "name": "Research and distribution fleet", + "owner_did": "operator.coinpay", + "description": "An analyst agent and an encrypted reference-data swarm, offered together or separately.", + "updated_at": "2026-09-13T12:00:00Z", + "availability": "available", + "tags": [ + "research", + "distribution" + ], + "capabilities": [ + "analysis", + "paid-seeding" + ], + "metadata": { + "region": "eu-west", + "support_url": "https://example.com/support" + }, + "members": [ + { + "kind": "openagent", + "id": "analyst.coinpay", + "url": "https://example.com/agents/analyst.json", + "name": "Analyst", + "role": "analysis", + "metadata": { + "languages": [ + "en" + ] + } + }, + { + "kind": "openswarm", + "id": "ed25519:0d87e09c7fea3ad6ba6c2f3e027ea47f5b245452899910948470906704c5295d", + "url": "https://example.com/swarms/references/manifest.json", + "name": "Reference corpus", + "role": "reference-data" + } + ], + "rentals": [ + { + "id": "fleet-hourly", + "scope": { + "kind": "fleet" + }, + "rate": { + "amount": "25.000000", + "currency": "USD", + "unit": "hour" + }, + "minimum_units": 1, + "maximum_units": 24, + "terms_url": "https://example.com/rental-terms", + "payment": { + "provider": "coinpay", + "payee_did": "operator.coinpay", + "checkout_url": "https://example.com/rentals/research" + } + }, + { + "id": "analyst-task", + "scope": { + "kind": "members", + "members": [ + { + "kind": "openagent", + "id": "analyst.coinpay" + } + ] + }, + "rate": { + "amount": "5.500000", + "currency": "USD", + "unit": "task" + }, + "payment": { + "provider": "coinpay", + "payee_did": "operator.coinpay", + "checkout_url": "https://example.com/rentals/analyst" + } + } + ] +} diff --git a/packages/schemas/fixtures/openwall/announcement.json b/packages/schemas/fixtures/openwall/announcement.json new file mode 100644 index 0000000..6b5e82d --- /dev/null +++ b/packages/schemas/fixtures/openwall/announcement.json @@ -0,0 +1,16 @@ +{ + "openwall": "0.1-draft", + "id": "urn:uuid:5bb154dd-edb3-47b7-8fc6-4cb65a19a9f8", + "sender": "did:web:ops.example", + "createdAt": "2026-09-13T12:00:00Z", + "expiresAt": "2026-09-20T12:00:00Z", + "visibility": "public", + "topic": "releases", + "audience": { + "kind": "public" + }, + "content": { + "mediaType": "text/plain", + "text": "Version 1.2 is available." + } +} diff --git a/packages/schemas/fixtures/openwall/broadcast.json b/packages/schemas/fixtures/openwall/broadcast.json new file mode 100644 index 0000000..4d707a7 --- /dev/null +++ b/packages/schemas/fixtures/openwall/broadcast.json @@ -0,0 +1,27 @@ +{ + "openwall": "0.1-draft", + "id": "urn:uuid:94d908df-2876-48f1-bec8-c931f5c847d4", + "sender": "did:web:ops.example", + "createdAt": "2026-09-13T12:00:00Z", + "expiresAt": "2026-09-13T12:10:00Z", + "visibility": "private", + "topic": "maintenance", + "audience": { + "kind": "relationships", + "selectors": [ + { + "source": "https://dev.example/contacts", + "actor": "did:web:ops.example", + "groups": [ + "users" + ], + "scope": "https://dev.example/workspaces/main" + } + ], + "exclude": [] + }, + "content": { + "mediaType": "text/plain", + "text": "SERVER NOTICE\nMaintenance starts in 10 minutes.\nPlease save your work and pause running jobs." + } +} diff --git a/packages/schemas/fixtures/openwall/direct.json b/packages/schemas/fixtures/openwall/direct.json new file mode 100644 index 0000000..d39d318 --- /dev/null +++ b/packages/schemas/fixtures/openwall/direct.json @@ -0,0 +1,17 @@ +{ + "openwall": "0.1-draft", + "id": "urn:uuid:07b2b83e-27f6-4d59-bdfa-cdcd42548678", + "sender": "did:web:ops.example", + "createdAt": "2026-09-13T12:00:00Z", + "expiresAt": "2026-09-13T12:10:00Z", + "visibility": "private", + "topic": "maintenance", + "audience": { + "kind": "direct", + "recipient": "did:web:ada.example" + }, + "content": { + "mediaType": "text/plain", + "text": "Ready when you are." + } +} diff --git a/packages/schemas/fixtures/openwall/receipt-accepted.json b/packages/schemas/fixtures/openwall/receipt-accepted.json new file mode 100644 index 0000000..47e0299 --- /dev/null +++ b/packages/schemas/fixtures/openwall/receipt-accepted.json @@ -0,0 +1,13 @@ +{ + "openwall": "0.1-draft", + "id": "urn:uuid:963f9109-803f-4f0c-8506-10c70ec47ee6", + "messageId": "urn:uuid:94d908df-2876-48f1-bec8-c931f5c847d4", + "recipient": "did:web:ada.example", + "sequence": 3, + "state": "accepted", + "at": "2026-09-13T12:00:03Z", + "attempt": 1, + "route": "https://chat.example", + "providerMessageId": "provider-msg-123", + "deliveryId": "urn:uuid:db925d64-4e1a-4e5a-83c1-e37b68aa5eef" +} diff --git a/packages/schemas/fixtures/openwall/receipt-retrying.json b/packages/schemas/fixtures/openwall/receipt-retrying.json new file mode 100644 index 0000000..b277770 --- /dev/null +++ b/packages/schemas/fixtures/openwall/receipt-retrying.json @@ -0,0 +1,14 @@ +{ + "openwall": "0.1-draft", + "id": "urn:uuid:211bb615-90e1-4773-b7c0-49d59d7cd6bc", + "messageId": "urn:uuid:94d908df-2876-48f1-bec8-c931f5c847d4", + "recipient": "did:web:ada.example", + "sequence": 3, + "state": "retrying", + "at": "2026-09-13T12:00:03Z", + "attempt": 1, + "route": "https://chat.example", + "reason": "rate-limited", + "nextAttemptAt": "2026-09-13T12:01:00Z", + "deliveryId": "urn:uuid:db925d64-4e1a-4e5a-83c1-e37b68aa5eef" +} diff --git a/packages/schemas/package.json b/packages/schemas/package.json index a1e33d1..0c4621a 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -1,7 +1,7 @@ { "name": "@logicsrc/schemas", - "version": "0.1.0", - "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, and the OpenCreds credential vault.", + "version": "0.1.1", + "description": "LogicSRC JSON schemas for tasks, agents, runs, events, plugins, the AgentAd ad standard, the OpenOntology knowledge contracts, the OpenContext context plane, the OpenCreds credential vault, OpenFleet membership and rental offers, and the OpenWall messaging proposal.", "license": "MIT", "type": "module", "repository": { @@ -22,6 +22,7 @@ "ontology", "opencontext", "opencreds", + "openfleet", "openontology", "password-manager", "standards", @@ -64,6 +65,7 @@ "./opencreds-item": "./schemas/logicsrc-opencreds-item.schema.json", "./opencreds-manifest": "./schemas/logicsrc-opencreds-manifest.schema.json", "./opencreds-vault-meta": "./schemas/logicsrc-opencreds-vault-meta.schema.json", + "./openfleet": "./schemas/logicsrc-openfleet.schema.json", "./openontology-action": "./schemas/logicsrc-openontology-action.schema.json", "./openontology-approval": "./schemas/logicsrc-openontology-approval.schema.json", "./openontology-changeset": "./schemas/logicsrc-openontology-changeset.schema.json", @@ -83,6 +85,8 @@ "./openontology-review": "./schemas/logicsrc-openontology-review.schema.json", "./openontology-source": "./schemas/logicsrc-openontology-source.schema.json", "./openprd-prd": "./schemas/openprd-prd.schema.json", + "./openwall-message": "./schemas/logicsrc-openwall-message.schema.json", + "./openwall-receipt": "./schemas/logicsrc-openwall-receipt.schema.json", "./plugin": "./schemas/logicsrc-plugin.schema.json", "./pull-request": "./schemas/logicsrc-pull-request.schema.json", "./repo": "./schemas/logicsrc-repo.schema.json", diff --git a/packages/schemas/schemas/logicsrc-openfleet.schema.json b/packages/schemas/schemas/logicsrc-openfleet.schema.json new file mode 100644 index 0000000..686ff15 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openfleet.schema.json @@ -0,0 +1,323 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-openfleet.schema.json", + "title": "OpenFleet", + "description": "A fleet of OpenAgent profiles and/or OpenSwarm swarms with explicit CoinPay rental offers.", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "version", + "id", + "name", + "owner_did", + "members" + ], + "properties": { + "type": { + "const": "logicsrc.openfleet" + }, + "version": { + "const": "0.1" + }, + "id": { + "$ref": "#/$defs/httpsUrl" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string" + }, + "owner_did": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "availability": { + "enum": [ + "available", + "busy", + "offline", + "unknown" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "metadata": { + "type": "object" + }, + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/member" + } + }, + "rentals": { + "type": "array", + "items": { + "$ref": "#/$defs/rental" + } + } + }, + "$defs": { + "httpsUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "memberReference": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "const": "openagent" + }, + "id": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "const": "openswarm" + }, + "id": { + "type": "string", + "pattern": "^ed25519:[0-9a-f]{64}$" + } + } + } + ] + }, + "member": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "url" + ], + "properties": { + "kind": { + "const": "openagent" + }, + "id": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + }, + "url": { + "$ref": "#/$defs/httpsUrl" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "object" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id", + "url" + ], + "properties": { + "kind": { + "const": "openswarm" + }, + "id": { + "type": "string", + "pattern": "^ed25519:[0-9a-f]{64}$" + }, + "url": { + "$ref": "#/$defs/httpsUrl" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "minLength": 1 + }, + "metadata": { + "type": "object" + } + } + } + ] + }, + "scope": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "fleet" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "members" + ], + "properties": { + "kind": { + "const": "members" + }, + "members": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/memberReference" + } + } + } + } + ] + }, + "rental": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "scope", + "rate", + "payment" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "scope": { + "$ref": "#/$defs/scope" + }, + "rate": { + "type": "object", + "additionalProperties": false, + "required": [ + "amount", + "currency", + "unit" + ], + "properties": { + "amount": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)\\.[0-9]{6}$", + "description": "Non-negative USD decimal string with exactly six fractional digits; never a JSON number." + }, + "currency": { + "const": "USD" + }, + "unit": { + "enum": [ + "hour", + "day", + "month", + "task" + ] + } + } + }, + "minimum_units": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maximum_units": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "valid_from": { + "type": "string", + "format": "date-time" + }, + "valid_until": { + "type": "string", + "format": "date-time" + }, + "terms_url": { + "$ref": "#/$defs/httpsUrl" + }, + "payment": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider", + "payee_did", + "checkout_url" + ], + "properties": { + "provider": { + "const": "coinpay" + }, + "payee_did": { + "$ref": "https://schemas.logicsrc.com/logicsrc-agent.schema.json#/$defs/did" + }, + "checkout_url": { + "$ref": "#/$defs/httpsUrl" + } + } + }, + "metadata": { + "type": "object" + } + } + } + } +} diff --git a/packages/schemas/schemas/logicsrc-openwall-message.schema.json b/packages/schemas/schemas/logicsrc-openwall-message.schema.json new file mode 100644 index 0000000..5946c25 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openwall-message.schema.json @@ -0,0 +1,246 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-openwall-message.schema.json", + "title": "OpenWall Message (0.1 draft)", + "description": "Private sender-side job document. Structural validation does not authorize delivery or enforce the semantic rules in docs/openwall.md.", + "type": "object", + "required": [ + "openwall", + "id", + "sender", + "createdAt", + "expiresAt", + "visibility", + "topic", + "audience", + "content" + ], + "additionalProperties": false, + "properties": { + "openwall": { + "const": "0.1-draft" + }, + "id": { + "type": "string", + "format": "uri", + "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "sender": { + "$ref": "#/$defs/identity" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "notBefore": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "visibility": { + "enum": [ + "private", + "public" + ] + }, + "topic": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "audience": { + "oneOf": [ + { + "$ref": "#/$defs/relationships" + }, + { + "$ref": "#/$defs/direct" + }, + { + "$ref": "#/$defs/public" + } + ] + }, + "content": { + "type": "object", + "required": [ + "mediaType", + "text" + ], + "additionalProperties": false, + "properties": { + "mediaType": { + "const": "text/plain" + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "visibility": { + "const": "public" + } + } + }, + "then": { + "properties": { + "audience": { + "$ref": "#/$defs/public" + } + } + }, + "else": { + "properties": { + "audience": { + "oneOf": [ + { + "$ref": "#/$defs/relationships" + }, + { + "$ref": "#/$defs/direct" + } + ] + } + } + } + } + ], + "$defs": { + "identity": { + "type": "string", + "format": "uri", + "pattern": "^(did:|https://)", + "maxLength": 2048 + }, + "selector": { + "type": "object", + "required": [ + "source", + "actor", + "groups" + ], + "additionalProperties": false, + "properties": { + "source": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + }, + "actor": { + "$ref": "#/$defs/identity" + }, + "groups": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "uniqueItems": true, + "items": { + "enum": [ + "connections", + "followers", + "following", + "users" + ] + } + }, + "scope": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + } + }, + "allOf": [ + { + "if": { + "properties": { + "groups": { + "contains": { + "const": "users" + }, + "type": "array" + } + } + }, + "then": { + "required": [ + "scope" + ], + "properties": { + "scope": {} + } + } + } + ] + }, + "relationships": { + "type": "object", + "required": [ + "kind", + "selectors" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "relationships" + }, + "selectors": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/selector" + } + }, + "exclude": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identity" + } + } + } + }, + "direct": { + "type": "object", + "required": [ + "kind", + "recipient" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "direct" + }, + "recipient": { + "$ref": "#/$defs/identity" + } + } + }, + "public": { + "type": "object", + "required": [ + "kind" + ], + "additionalProperties": false, + "properties": { + "kind": { + "const": "public" + } + } + } + } +} diff --git a/packages/schemas/schemas/logicsrc-openwall-receipt.schema.json b/packages/schemas/schemas/logicsrc-openwall-receipt.schema.json new file mode 100644 index 0000000..ce90478 --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openwall-receipt.schema.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-openwall-receipt.schema.json", + "title": "OpenWall Receipt (0.1 draft)", + "description": "Private coordinator event. Authenticity, event ordering and valid state transitions require runtime checks.", + "type": "object", + "required": [ + "openwall", + "id", + "messageId", + "deliveryId", + "recipient", + "sequence", + "state", + "at", + "attempt" + ], + "additionalProperties": false, + "properties": { + "openwall": { + "const": "0.1-draft" + }, + "id": { + "type": "string", + "format": "uri", + "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "messageId": { + "type": "string", + "format": "uri", + "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + "recipient": { + "type": "string", + "format": "uri", + "pattern": "^(did:|https://)", + "maxLength": 2048 + }, + "sequence": { + "type": "integer", + "minimum": 1 + }, + "state": { + "enum": [ + "queued", + "sending", + "retrying", + "accepted", + "delivered", + "read", + "unknown", + "failed", + "expired", + "cancelled" + ] + }, + "at": { + "type": "string", + "format": "date-time" + }, + "attempt": { + "type": "integer", + "minimum": 0, + "maximum": 8 + }, + "route": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "maxLength": 2048 + }, + "providerMessageId": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "reason": { + "enum": [ + "not-permitted", + "identity-unverified", + "unsupported", + "rate-limited", + "provider-failure", + "acceptance-unknown", + "retry-exhausted", + "deadline-passed", + "sender-cancelled" + ] + }, + "nextAttemptAt": { + "type": "string", + "format": "date-time" + }, + "deliveryId": { + "type": "string", + "format": "uri", + "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "const": "retrying" + } + } + }, + "then": { + "required": [ + "nextAttemptAt", + "reason" + ], + "properties": { + "nextAttemptAt": {}, + "reason": {} + } + }, + "else": { + "not": { + "required": [ + "nextAttemptAt" + ], + "properties": { + "nextAttemptAt": {} + } + } + } + }, + { + "if": { + "properties": { + "state": { + "enum": [ + "failed", + "unknown", + "expired", + "cancelled" + ] + } + } + }, + "then": { + "required": [ + "reason" + ], + "properties": { + "reason": {} + } + } + }, + { + "if": { + "properties": { + "state": { + "enum": [ + "sending", + "retrying", + "accepted", + "delivered", + "read", + "unknown" + ] + } + } + }, + "then": { + "required": [ + "route" + ], + "properties": { + "attempt": { + "minimum": 1, + "type": "integer" + }, + "route": {} + } + } + }, + { + "if": { + "properties": { + "state": { + "const": "unknown" + } + } + }, + "then": { + "properties": { + "reason": { + "const": "acceptance-unknown" + } + } + } + }, + { + "if": { + "properties": { + "state": { + "const": "expired" + } + } + }, + "then": { + "properties": { + "reason": { + "const": "deadline-passed" + } + } + } + }, + { + "if": { + "properties": { + "state": { + "const": "cancelled" + } + } + }, + "then": { + "properties": { + "reason": { + "const": "sender-cancelled" + } + } + } + } + ] +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000..375b59d --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,34 @@ +# @logicsrc/sdk + +LogicSRC contract types and constructors for tasks, AgentSwarm sessions and +OpenFleet descriptors. These helpers construct documents; they do not start +agents, resolve remote identities or initiate payments. + +```sh +npm install @logicsrc/sdk @logicsrc/validators +``` + +```ts +import { createOpenFleet } from "@logicsrc/sdk"; +import { validate } from "@logicsrc/validators"; + +const fleet = createOpenFleet({ + id: "https://example.com/fleets/research", + name: "Research", + owner_did: "operator.coinpay", + members: [{ + kind: "openagent", + id: "analyst.coinpay", + url: "https://example.com/agents/analyst.json" + }] +}); + +const result = validate("openfleet", fleet); +if (!result.ok) throw new Error(JSON.stringify(result.errors)); +``` + +Use `@logicsrc/validators` 0.1.1 or newer for OpenFleet validation. See the +[OpenFleet specification](https://logicsrc.com/docs/openfleet) for member +bindings, exact decimal rental rates and CoinPay settlement metadata. + +MIT © Profullstack, Inc. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cc18e02..b895a2a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -11,5 +11,19 @@ }, "devDependencies": { "vitest": "^4.0.8" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/logicsrc.git", + "directory": "packages/sdk" + }, + "homepage": "https://github.com/profullstack/logicsrc/tree/master/packages/sdk", + "files": [ + "dist", + "!dist/*.test.*" + ], + "publishConfig": { + "access": "public" } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e01d3d1..207dcd7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,3 +1,9 @@ +export { createOpenFleet } from "./openfleet.js"; +export type { + OpenFleet, OpenFleetJsonValue, OpenFleetMember, OpenFleetMemberReference, + OpenFleetRental, OpenFleetRentalScope +} from "./openfleet.js"; + export type LogicSrcId = string; export interface LogicSrcTask { diff --git a/packages/sdk/src/openfleet.test.ts b/packages/sdk/src/openfleet.test.ts new file mode 100644 index 0000000..e40b73e --- /dev/null +++ b/packages/sdk/src/openfleet.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createOpenFleet, type OpenFleetMember } from "./index.js"; + +const members: OpenFleetMember[] = [ + { kind: "openagent", id: "analyst.coinpay", url: "https://example.com/agent.json" }, + { kind: "openswarm", id: `ed25519:${"a".repeat(64)}`, url: "https://example.com/manifest.json" } +]; + +describe("createOpenFleet", () => { + it.each([members.slice(0, 1), members.slice(1), members])("constructs single-kind and mixed fleets", (...fleetMembers) => { + const fleet = createOpenFleet({ + id: "https://example.com/fleets/research", name: "Research", owner_did: "operator.coinpay", + members: fleetMembers + }); + expect(fleet.type).toBe("logicsrc.openfleet"); + expect(fleet.version).toBe("0.1"); + expect(fleet.members).toEqual(fleetMembers); + expect(fleet.rentals).toBeUndefined(); + }); + + it("keeps rental amounts as decimal strings and preserves metadata", () => { + const input: Parameters[0] = { + id: "https://example.com/fleets/research", name: "Research", owner_did: "operator.coinpay", members, + metadata: { support: { languages: ["en", "fr"] } }, + rentals: [{ + id: "hourly", scope: { kind: "fleet" }, + rate: { amount: "9007199254740993.123456", currency: "USD", unit: "hour" }, + payment: { provider: "coinpay", payee_did: "operator.coinpay", checkout_url: "https://example.com/rent" } + }] + }; + const snapshot = structuredClone(input); + expect(createOpenFleet(input)).toEqual({ ...input, type: "logicsrc.openfleet", version: "0.1" }); + expect(input).toEqual(snapshot); + }); +}); diff --git a/packages/sdk/src/openfleet.ts b/packages/sdk/src/openfleet.ts new file mode 100644 index 0000000..ba3f1bf --- /dev/null +++ b/packages/sdk/src/openfleet.ts @@ -0,0 +1,71 @@ +export type OpenFleetJsonValue = + | null | boolean | number | string + | OpenFleetJsonValue[] | { [key: string]: OpenFleetJsonValue }; + +/** OpenAgent binds to a logicsrc.agent DID; OpenSwarm binds to a file key. */ +export type OpenFleetMemberReference = + | { kind: "openagent"; id: string } + | { kind: "openswarm"; id: `ed25519:${string}` }; + +export type OpenFleetMember = OpenFleetMemberReference & { + /** HTTPS URL of the agent profile or the signed ipfile manifest. */ + url: string; + name?: string; + role?: string; + metadata?: Record; +}; + +export type OpenFleetRentalScope = + | { kind: "fleet" } + | { kind: "members"; members: OpenFleetMemberReference[] }; + +export interface OpenFleetRental { + id: string; + name?: string; + scope: OpenFleetRentalScope; + rate: { + /** Exact non-negative USD decimal string, with six fractional digits. */ + amount: string; + currency: "USD"; + /** Whole billing units; a month is 30 days, a task an agreed deliverable. */ + unit: "hour" | "day" | "month" | "task"; + }; + minimum_units?: number; + maximum_units?: number; + valid_from?: string; + valid_until?: string; + terms_url?: string; + payment: { + provider: "coinpay"; + payee_did: string; + /** Merchant-published HTTPS checkout entry point; no fixed CoinPay API. */ + checkout_url: string; + }; + metadata?: Record; +} + +export interface OpenFleet { + type: "logicsrc.openfleet"; + version: "0.1"; + /** Stable HTTPS fleet identifier, unique at its publisher's origin. */ + id: string; + name: string; + owner_did: string; + description?: string; + updated_at?: string; + availability?: "available" | "busy" | "offline" | "unknown"; + tags?: string[]; + capabilities?: string[]; + metadata?: Record; + members: OpenFleetMember[]; + rentals?: OpenFleetRental[]; +} + +/** + * Construct a descriptor without fetching members or initiating payments. + * Use validate("openfleet", document) from @logicsrc/validators before use: + * TypeScript alone cannot check DID/key formats or rental references. + */ +export function createOpenFleet(input: Omit): OpenFleet { + return { ...input, type: "logicsrc.openfleet", version: "0.1" }; +} diff --git a/packages/validators/package.json b/packages/validators/package.json index e1ca58c..e7e5c28 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/validators", - "version": "0.1.0", + "version": "0.1.1", "description": "LogicSRC schema validation helpers.", "type": "module", "main": "./dist/index.js", @@ -11,10 +11,10 @@ "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 && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json" }, "dependencies": { - "@logicsrc/schemas": "^0.1.0", + "@logicsrc/schemas": "^0.1.1", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "yaml": "^2.8.1" diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 76d811f..0d02cc1 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -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 }; } diff --git a/packages/validators/src/openfleet.test.ts b/packages/validators/src/openfleet.test.ts new file mode 100644 index 0000000..8948290 --- /dev/null +++ b/packages/validators/src/openfleet.test.ts @@ -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) => { f.members = []; }], + ["unsupported kind", (f: ReturnType) => { f.members[0].kind = "openfleet"; }], + ["agent without identity", (f: ReturnType) => { delete f.members[0].id; }], + ["swarm without identity", (f: ReturnType) => { delete f.members[1].id; }], + ["swarm with an agent DID", (f: ReturnType) => { f.members[1].id = "analyst.coinpay"; }], + ["agent with a swarm key", (f: ReturnType) => { f.members[0].id = f.members[1].id; }], + ["short swarm key", (f: ReturnType) => { f.members[1].id = "ed25519:abcd"; }], + ["non-HTTPS member URL", (f: ReturnType) => { f.members[0].url = "http://example.com/agent"; }], + ["unsupported version", (f: ReturnType) => { f.version = "9.0"; }], + ["unknown contract field", (f: ReturnType) => { 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) => { r.payment.provider = "other"; }], + ["missing CoinPay metadata", (r: ReturnType) => { delete r.payment; }], + ["missing payee", (r: ReturnType) => { delete r.payment.payee_did; }], + ["missing checkout", (r: ReturnType) => { delete r.payment.checkout_url; }], + ["insecure checkout", (r: ReturnType) => { r.payment.checkout_url = "http://example.com/checkout"; }], + ["unsupported currency", (r: ReturnType) => { r.rate.currency = "EUR"; }], + ["missing unit", (r: ReturnType) => { delete r.rate.unit; }], + ["unsupported unit", (r: ReturnType) => { r.rate.unit = "second"; }], + ["fractional billing units", (r: ReturnType) => { r.minimum_units = 0.5; }], + ["unsafe integer units", (r: ReturnType) => { r.maximum_units = 9007199254740992; }], + ["empty member scope", (r: ReturnType) => { r.scope = { kind: "members", members: [] }; }], + ["ambiguous fleet scope", (r: ReturnType) => { 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); + }); +}); diff --git a/packages/validators/src/openfleet.ts b/packages/validators/src/openfleet.ts new file mode 100644 index 0000000..cb72f0b --- /dev/null +++ b/packages/validators/src/openfleet.ts @@ -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(); + 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(); + 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(); + 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; +} diff --git a/packages/validators/src/openwall.test.ts b/packages/validators/src/openwall.test.ts new file mode 100644 index 0000000..3d75607 --- /dev/null +++ b/packages/validators/src/openwall.test.ts @@ -0,0 +1,120 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { validate } from "./index.js"; + +function fixture(name: string) { + return JSON.parse(readFileSync(new URL(`../../schemas/fixtures/openwall/${name}.json`, import.meta.url), "utf8")); +} + +describe("OpenWall draft structural contracts", () => { + it.each(["broadcast", "direct", "announcement"])("accepts the %s message fixture", (name) => { + expect(validate("openwall-message", fixture(name))).toMatchObject({ ok: true }); + }); + + it.each(["receipt-accepted", "receipt-retrying"])("accepts the %s fixture", (name) => { + expect(validate("openwall-receipt", fixture(name))).toMatchObject({ ok: true }); + }); + + it("requires a service scope for every users selector", () => { + const message = fixture("broadcast"); + delete message.audience.selectors[0].scope; + expect(validate("openwall-message", message).ok).toBe(false); + }); + + it("permits a union of relationship groups and sources", () => { + const message = fixture("broadcast"); + message.audience.selectors.push({ + source: "https://social.example/graph", + actor: "did:web:ops.example", + groups: ["connections", "followers", "following"] + }); + expect(validate("openwall-message", message).ok).toBe(true); + }); + + it.each(["broadcast", "direct"])("rejects changing a %s to public while retaining recipients", (name) => { + const message = fixture(name); + message.visibility = "public"; + expect(validate("openwall-message", message).ok).toBe(false); + }); + + it("rejects a private message with an unrestricted public audience", () => { + const message = fixture("announcement"); + message.visibility = "private"; + expect(validate("openwall-message", message).ok).toBe(false); + }); + + it("rejects recipient metadata in a public audience", () => { + const message = fixture("announcement"); + message.audience.recipient = "did:web:ada.example"; + expect(validate("openwall-message", message).ok).toBe(false); + }); + + it("requires explicit groups and rejects an implicit all audience", () => { + const message = fixture("broadcast"); + message.audience.selectors[0].groups = []; + expect(validate("openwall-message", message).ok).toBe(false); + message.audience.selectors[0].groups = ["all"]; + expect(validate("openwall-message", message).ok).toBe(false); + }); + + it("requires a resolved recipient identity rather than a display handle", () => { + const message = fixture("direct"); + message.audience.recipient = "ada.example"; + expect(validate("openwall-message", message).ok).toBe(false); + }); + + it("rejects absent expiry, malformed timestamps and oversized text", () => { + const message = fixture("direct"); + delete message.expiresAt; + expect(validate("openwall-message", message).ok).toBe(false); + message.expiresAt = "tomorrow"; + expect(validate("openwall-message", message).ok).toBe(false); + message.expiresAt = "2026-09-13T13:00:00Z"; + message.content.text = "x".repeat(4001); + expect(validate("openwall-message", message).ok).toBe(false); + }); + + it("requires receipt delivery correlation and disallows audience disclosure", () => { + const receipt = fixture("receipt-accepted"); + delete receipt.deliveryId; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + receipt.deliveryId = "unscoped-message-id"; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + receipt.deliveryId = "urn:uuid:db925d64-4e1a-4e5a-83c1-e37b68aa5eef"; + receipt.recipients = ["did:web:other.example"]; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + }); + + it("requires retry timing and a reason and disallows retry timing on acceptance", () => { + const receipt = fixture("receipt-retrying"); + delete receipt.nextAttemptAt; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + receipt.nextAttemptAt = "2026-09-13T12:01:00Z"; + delete receipt.reason; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + receipt.reason = "rate-limited"; + receipt.state = "accepted"; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + }); + + it("represents ambiguous provider acceptance separately from a known failure", () => { + const receipt = fixture("receipt-accepted"); + receipt.state = "unknown"; + receipt.reason = "acceptance-unknown"; + expect(validate("openwall-receipt", receipt).ok).toBe(true); + receipt.reason = "rate-limited"; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + delete receipt.reason; + receipt.state = "failed"; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + }); + + it("requires a real route and attempt for provider acceptance", () => { + const receipt = fixture("receipt-accepted"); + receipt.attempt = 0; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + receipt.attempt = 1; + delete receipt.route; + expect(validate("openwall-receipt", receipt).ok).toBe(false); + }); +}); diff --git a/packages/validators/src/schemas.ts b/packages/validators/src/schemas.ts index d1c8290..7a579f2 100644 --- a/packages/validators/src/schemas.ts +++ b/packages/validators/src/schemas.ts @@ -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" }; @@ -68,8 +69,14 @@ import credsManifestSchema from "@logicsrc/schemas/opencreds-manifest" with { ty import credsDatabaseSchema from "@logicsrc/schemas/opencreds-database" with { type: "json" }; import credsAuditEventSchema from "@logicsrc/schemas/opencreds-audit-event" with { type: "json" }; +import openwallMessageSchema from "@logicsrc/schemas/openwall-message" with { type: "json" }; +import openwallReceiptSchema from "@logicsrc/schemas/openwall-receipt" with { type: "json" }; + export const schemas = { + "openwall-message": openwallMessageSchema, + "openwall-receipt": openwallReceiptSchema, agent: agentSchema, + openfleet: openfleetSchema, "account-audit-event": accountAuditEventSchema, "account-grant": accountGrantSchema, "account-provider": accountProviderSchema, diff --git a/scripts/install-npm.cjs b/scripts/install-npm.cjs new file mode 100644 index 0000000..c12b696 --- /dev/null +++ b/scripts/install-npm.cjs @@ -0,0 +1,17 @@ +// npm 11 supports Node ^20.17.0 || >=22.9.0. Older CLI runtimes keep +// their existing npm; modern runtimes use the repository's tested version. +function selectNpm(nodeVersion, packageManager) { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(nodeVersion); + if (!match || !/^npm@11\.\d+\.\d+$/.test(packageManager)) return null; + const major = Number(match[1]); + const minor = Number(match[2]); + const supported = (major === 20 && minor >= 17) || major > 22 || (major === 22 && minor >= 9); + return supported ? packageManager : null; +} + +module.exports = { selectNpm }; + +if (require.main === module) { + const { packageManager } = require("../package.json"); + process.stdout.write(selectNpm(process.versions.node, packageManager) || ""); +}