Marketplace & wallet
Listings, orders, reviews, credits and the wallet.
Every schema below is exported from @novaterra/contracts and validated on both sides of the wire. Source: packages/contracts/src/market.ts.
ListingKind
module is the kind that makes the marketplace a package manager rather than a shop for content: it ships EXECUTABLE CODE from another node’s owner. Everything else on this list is data the host interprets (a generator is steps, a theme is colours, a service is a brief); a module is a plugin package, and buying one has to end at the same grant screen a local install ends at. See ModulePackage below and apps/api/src/modules/market/packaging.ts. role and hire are the two halves of what was one agent kind until 2026-09-08, and the split is constitutional rather than cosmetic (Article V: “An AI citizen may be hired, not owned”; design/PRIMITIVES.md §5.6.1 V7, §5.6.2): role — sells the COSTUME: name, role, skills, tier and working prompt. A spark raised for the BUYER wears it (market/delivery.ts). Nobody changes hands, so nobody has to consent: what is sold is a description of work, not a worker. hire — sells a SERVICE FROM AN EXISTING CITIZEN, which needs two things a role listing does not: that citizen’s own consent, and a scope saying what they agreed to. Both exist since Council 038 5-B (./hire.ts): the consent is the being’s SIGNED hiring policy, and a hire listing is written by that policy and by nothing else — POST/PUT /api/market/listings still refuse the kind, because a listing typed by hand would be a consent nobody signed. A hire is not bought either: deliver() has no path for it, and the buy route points at POST /api/hires, where the being’s policy decides the offer and the hirer signs a grant bounded to the agreed scope. Neither exists yet, so a (true until 2026-09-27). hire listing cannot be createdagent is GONE rather than kept as an alias. Every row that carried it was a costume copy — that is all delivery has ever done with one, and since 2026-09-08 it cannot transfer a being at all — so ensureMarketTables() migrates those rows to role. An alias would have kept a word that reads as “you are buying a citizen” alive in a shop that does not sell them.
'generator' | 'skill' | 'role' | 'hire' | 'service' | 'theme' | 'module'
Listing
| Field | Type | Notes |
|---|---|---|
id | Id | |
sellerId | Id | |
kind | ListingKind | |
refId | Id | null | |
title | string | |
description | string | |
cover | string | null | optional · default null |
priceCredits | number (≥ 0) | |
rating | number (≥ 0, ≤ 5) | optional · default 0 |
ratingCount | number | optional · default 0 |
sales | number | optional · default 0 |
tags | string[] | optional · default [] |
status | 'active' | 'paused' | 'sold_out' | 'in_review' | optional · default 'active' |
brief | string | null | optional · default null |
createdAt | ISODate | |
honesty | { declared: number (safeint, ≥ 0); resolved: number (safeint, ≥ 0); wired: number (safeint, ≥ 0); gated: number (safeint, ≥ 0); endToEnd: boolean; setupComplexity: 'none' | 'keys' | 'connections'; requiredConfig: string[]; runs: number (safeint, ≥ 0); successes: number (safeint, ≥ 0); onlyRanOnSellersNode: boolean; templatedSteps: number (safeint, ≥ 0) } | null | optional · default null |
Order
| Field | Type | Notes |
|---|---|---|
id | Id | |
buyerId | Id | |
listingId | Id | |
sellerId | Id | |
credits | number | |
status | 'pending' | 'paid' | 'delivered' | 'refunded' | |
note | string | optional · default '' |
createdAt | ISODate |
Wallet
| Field | Type | Notes |
|---|---|---|
beingId | Id | |
credits | number | |
lifetimeEarned | number | optional · default 0 |
lifetimeSpent | number | optional · default 0 |
Review
| Field | Type | Notes |
|---|---|---|
id | Id | |
listingId | Id | |
authorId | Id | |
rating | number (≥ 1, ≤ 5) | |
body | string | |
createdAt | ISODate |
Purchase
What a buyer received for an order: the cloned/created thing, or an access grant for skills. (additive, market slice)
| Field | Type | Notes |
|---|---|---|
id | Id | |
orderId | Id | |
buyerId | Id | |
listingId | Id | |
kind | ListingKind | |
deliveredId | string | null | |
createdAt | ISODate |
SellerDashboard
GET /api/market/mine: the seller’s stall at a glance. (additive, market slice)
| Field | Type | Notes |
|---|---|---|
listings | Listing[] | |
sales | Order[] | |
earnings | { total: number; last30d: number; count: number } | |
wallet | Wallet |
MarketCoverSource
Where a listing’s cover picture came from. One value, and that is the point rather than an oversight. ArchiveCover has three because a project MAKES things and a real rendering of one of them is truer and free; a listing makes nothing, so the only picture that can exist for one is art an image model drew from the title and description. The enum is declared anyway — and the field is never absent — because the whole honesty argument in apps/api/src/modules/archive/covers.ts rests on a cover always saying out loud that it was made up.
'generated'
MarketCoverCandidate
What building this listing’s cover would produce if it were asked now. none means there is no image model configured, so the button that spends money is not offered at all. Same split, and same reason, as ArchiveCoverCandidate: source is what the stored picture is, candidate is what the next build would cost.
'generated' | 'none'
MarketCover
One listing’s cover, present or merely possible. Mirrors ArchiveCover (contracts/archive.ts) minus the artefact-specific half: a listing has no artefactId/artefactName because it has no files of its own to be a picture of. The bytes are served by GET /api/market/listings/:id/cover/image rather than carried here as a URL, the same split the Archive already uses — a URL in a JSON body is a second place for the storage layout to be written down.
| Field | Type | Notes |
|---|---|---|
listingId | Id | |
listingTitle | string | |
source | MarketCoverSource | null | optional · default null |
candidate | MarketCoverCandidate | |
width | number (safeint, ≥ 0) | optional · default 0 |
height | number (safeint, ≥ 0) | optional · default 0 |
prompt | string | null | optional · default null |
model | string | null | optional · default null |
costUsd | number (≥ 0) | optional · default 0 |
builtAt | ISODate | null | optional · default null |
stale | boolean | optional · default false |
MarketCoversResponse
Every drawn cover the viewer can see, in one request. Mirrors ArchiveCoversResponse. ==================== WHY A BATCH READ HAD TO EXIST ==================== The per-listing read (GET /api/market/listings/:id/cover) is enough for the slide-over, which has one listing open. It is not enough for the GRID, and the grid is where a cover earns its keep: CoverArt draws twenty-odd cards at once and cannot make twenty-odd requests to find out which of them have a picture. The consequence of not having this was not a slow grid — it was a feature that could not be seen. buildListingCover writes listing_covers; CoverArt reads Listing.cover; the two were joined in exactly one place, in memory, inside ListingDetail. So a drawn cover appeared in the slide-over and NOWHERE ELSE, and the shelf the whole thing exists for never changed. ==================== WHY THE SERVER DOES NOT JUST SET Listing.cover ==================== That is the obvious join and it is wrong twice over. Listing.cover is a column a seller types a URL into, and it travels to other nodes inside a federated listing. A root-relative /api/market/... written into it resolves against whichever node is DISPLAYING the listing rather than the one holding the picture. And the URL cannot be built on the server at all: in a deployed split-origin build the web app’s API_BASE is a different origin from the page, and the API has no idea what it is. Only the client about to load the image knows which API it is talking to. So the server says WHICH listings have a picture, and the client says WHERE — which is the split the per-listing read already uses, extended to the only shape a grid can consume.
| Field | Type | Notes |
|---|---|---|
covers | MarketCover[] | |
spentUsd | number (≥ 0) | |
canGenerate | boolean |
ModulePackageFile
One file of a module package, as it crosses the wire. Text only, and that is a security decision rather than a limitation of the encoder. A module is an HTML document plus (optionally) a JavaScript entry the host hands to a sandbox; neither needs to be a binary. Refusing binary content means a listing cannot smuggle a native addon — the one thing that walks straight out of the worker tier (see CAPABILITY_ENFORCEMENT in plugins.ts) — through the marketplace. path is PluginRelPath, so .., a leading slash and a drive letter are refused at the contract, and the writer re-checks containment against the resolved path.
| Field | Type | Notes |
|---|---|---|
path | string (min 1, max 200, regex, custom, custom) | |
text | string (max 524288) |
ModulePackage
A module, packaged for sale: the manifest the grant screen will read, and the files it needs. The manifest is PluginManifest — the same schema a local install parses — so a package from a stranger is judged by exactly the rules a local directory is judged by, at the boundary, before anything is written to disk. Nothing here is trusted twice: the seller’s node validated it when the listing was published, and the buyer’s node validates it again on delivery, because the bytes in between belong to neither of them.
| Field | Type | Notes |
|---|---|---|
manifest | { apiVersion: string (regex); id: string (regex); version: string (regex); namespace: string (regex); name: string (min 1, max 60); description: string (min 1, max 600); icon: string (max 8); author: { name, url, email }; license: string (max 40); homepage: string (max 300) | null; repository: string (max 300) | null; keywords: string (max 32)[]; main: string (max 200) | null; capabilities: { kind, hosts, methods, reason } | { kind, access, scope, reason } | { kind, runtimes, reason } | { kind, tiers, maxCallsPerRun, reason } | { kind, access, quotaKb, reason } | { kind, access, reason } | { kind, reason } | { kind, providers, reason } | { kind, maxPerRun, reason }[]; extensions: { kind, id, title, description, icon, uses, category, inputSchema, outputSchema, requiresConnection, entry } | { kind, id, title, description, icon, uses, slug, inputsSchema, steps, outputKinds, priceCredits } | { kind, id, title, description, icon, uses, entry, defaultSize, minSize, configSchema, refreshMs, surfaces } | { kind, id, title, description, icon, uses, provider, auth, configSchema, grants, entry, sync, cadenceMs } | { kind, id, title, description, icon, uses, entry, outputKinds, mimeTypes, extensions, priority } | { kind, id, title, description, icon, uses, entry, outputKinds, mimeTypes, extensions, priority, savesThroughHost } | { kind, id, title, description, icon, uses, scheme, background, palette, glass, motion, fontDisplay, fontBody } | { kind, id, title, description, icon, uses, route, railIcon, document, widgets, viewers }[]; pricing: { model, credits }; minHostVersion: string (regex) | null } | |
files | ModulePackageFile[] | |
signature | { payload: { v, type, did, pluginId, version, namespace, manifest, files, signedAt }; signature: { alg, did, sig }; chain: { origin, current, links, commitment } | null } | null | optional · default null |
ModuleFileSummary
One file, as the review queue lists it: never its content, only what it is and its digest.
| Field | Type | Notes |
|---|---|---|
path | string | |
bytes | number (safeint, ≥ 0) | |
sha256 | string |
ModuleDeclaration
What a module listing DECLARES, extracted for a human to read before anything is installed. Every field is derived from the package’s manifest by the server. None of it is prose the seller wrote about themselves — the description in Listing is that, and it is rendered as text — this is the machine-readable part a decision can actually be made on.
| Field | Type | Notes |
|---|---|---|
pluginId | string (regex) | |
version | string (regex) | |
namespace | string (regex) | |
name | string | |
description | string | |
author | { name: string (min 1, max 80); url: string (max 200) | null; email: string (max 200) | null } | |
capabilities | { kind, reason, detail }[] | optional · default [] |
extensions | { kind, id, title, uses }[] | optional · default [] |
files | ModuleFileSummary[] | optional · default [] |
totalBytes | number (safeint, ≥ 0) | |
hasServerCode | boolean | optional · default false |
provenance | { state: ‘unsigned’ | ‘verified’ | ‘content-mismatch’ | ‘wrong-signer’ | ‘malformed’; standing: ‘unsigned’ | ‘first-sight’ | ‘known’ | ‘rotated’ | ‘changed’; did: string (regex) | null; signedAt: ISODate | null; pluginId: string (regex) | null; version: string (regex) | null; scope: string; knownDid: string (regex) | null; knownSince: ISODate | null; scopeDid: string (regex) | null; scopeSince: ISODate | null; scopeConflict: boolean; reason: string; refused: boolean; needsAcknowledgement: boolean } | null | optional · default null |
ModerationState
The review queue’s state for one listing. flagged is not an accusation, it is the resting state: a module listing is flagged the moment it is created and nothing but an owner’s decision moves it. rejected keeps the row so the same package cannot be re-listed and quietly forgotten about.
'flagged' | 'approved' | 'rejected'
ListingModeration
| Field | Type | Notes |
|---|---|---|
listingId | Id | |
state | ModerationState | |
note | string | optional · default '' |
flaggedAt | ISODate | |
decidedBy | Id | null | optional · default null |
decidedAt | ISODate | null | optional · default null |
ModerationItem
One row of the owner’s review queue: the listing, its state, and what its package declares.
| Field | Type | Notes |
|---|---|---|
listing | Listing | |
moderation | ListingModeration | |
declaration | ModuleDeclaration | null | optional · default null |
ModerationDecisionRequest
| Field | Type | Notes |
|---|---|---|
decision | 'approve' | 'reject' | |
note | string (overwrite, max 400) | optional · default '' |
Lineage
Where a delivered thing came from. A purchased generator, role card, theme or module is a CLONE, and until now the clone knew nothing about its origin: two identical generators in a Studio, one authored and one bought, were indistinguishable, and an update to the original was undiscoverable. One row per delivered artifact fixes both — forkedFrom is the source id on the seller’s side (null when the listing had no ref and the delivery was built from the listing itself, which is an original, not a fork).
| Field | Type | Notes |
|---|---|---|
id | string | |
kind | ListingKind | |
ownerId | Id | |
forkedFrom | string | null | optional · default null |
listingId | Id | |
orderId | Id | |
sellerId | Id | |
sourceVersion | string (regex) | null | optional · default null |
createdAt | ISODate |
LineageOrigin
What forkedFrom NAMES, in terms a person can read — worked out on the read path, never stored. Derived rather than persisted for the same reason Listing.honesty is (see its comment): a stored copy is a second place for “what this thing actually is” to be written down, and the answer changes under it. A generator the seller has since deleted stops being resolvable on the very next read, with nothing to reconcile. ==================== THE THING THIS MUST NOT DO ==================== It must not imply a resolution this node cannot perform. forkedFrom is an id minted on the SELLER’s side, and for a cross-node buy there is no lookup that will ever turn it into a row here. label is therefore null whenever this node has no name of its own to give, and null means “no name here” — never a guess assembled from the listing’s title, which would read as though the id had been resolved when nothing was resolved at all.
| Field | Type | Notes |
|---|---|---|
id | string | null | optional · default null |
kind | 'original' | 'capability' | 'local' | 'elsewhere' | |
label | string | null | optional · default null |
LineageView
Lineage plus what the origin looks like NOW, which is the half that makes an update discoverable.
| Field | Type | Notes |
|---|---|---|
lineage | Lineage | |
listing | Listing | null | optional · default null |
latestVersion | string (regex) | null | optional · default null |
updateAvailable | boolean | optional · default false |
origin | LineageOrigin | |
sellerHandle | string |
MarketNodeEndpoint
A node endpoint the Hall (or a node) will new URL() against. http(s), no credentials, no fragment - the same rule DirectoryRegistration.endpoint states, restated here so ./market.ts does not have to import ./directory.ts (which would be a new edge for one refinement).
string (min 8, max 300, custom)
FederatedMarketBinding
The Hall’s record that a personal node belongs to one of its members. Written by the member from their Hall session; memberId is the session’s being and is never a field a client sends. This is the whole of the credential link: a federation buy call signed by nodeDid is run as memberId, and nothing in the request body can change that.
| Field | Type | Notes |
|---|---|---|
nodeDid | string (regex) | |
memberId | Id | |
endpoint | MarketNodeEndpoint | |
label | string (max 80) | optional · default '' |
boundAt | ISODate | |
lastBuyAt | ISODate | null | optional · default null |
RegisterFederatedNodeRequest
POST /api/market/nodes - a Hall member registers the personal node they own.
| Field | Type | Notes |
|---|---|---|
nodeDid | string (regex) | |
endpoint | MarketNodeEndpoint |
MarketPurchase
The document a node sends the Hall to buy a listing. Signed by the NODE’s own key - a self-assertion, exactly like a SignedNodeDescriptor, so signature.did must equal did. nonce is not carried here: freshness rides in the SignedAttestation beside it, the same shape a federated message uses, so a replayed buy is refused by a spent nonce rather than by hoping a status check gets there first.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
type | 'novaterra.market.purchase' | |
did | string (regex) | |
listingId | Id | |
issuedAt | ISODate |
SignedMarketPurchase
| Field | Type | Notes |
|---|---|---|
payload | MarketPurchase | |
signature | { alg: 'ed25519'; did: string (regex); sig: string (regex) } |
MarketRedeliver
Re-request the delivery of an order the Hall has already charged for. Charges nothing again.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
type | 'novaterra.market.redeliver' | |
did | string (regex) | |
orderId | Id | |
issuedAt | ISODate |
SignedMarketRedeliver
| Field | Type | Notes |
|---|---|---|
payload | MarketRedeliver | |
signature | { alg: 'ed25519'; did: string (regex); sig: string (regex) } |
MarketHelloRequest
POST /api/federation/market/hello - “which machine am I, give me a nonce to answer.”
| Field | Type | Notes |
|---|---|---|
descriptor | unknown |
MarketHello
| Field | Type | Notes |
|---|---|---|
v | 1 | |
protocol | number (safeint) | |
challenge | { payload: { v, type, nonce, issuer, audience, issuedAt, expiresAt }; signature: { alg, did, sig } } | null | optional · default null |
accepted | boolean | |
reason | string (max 300) | optional · default '' |
MarketBuyRequest
POST /api/federation/market/buy and /redeliver.
| Field | Type | Notes |
|---|---|---|
descriptor | unknown | |
purchase | SignedMarketPurchase | optional |
redeliver | SignedMarketRedeliver | optional |
attestation | { payload: { v, type, nonce, audience, subject, issuedAt, expiresAt }; signature: { alg, did, sig } } |
MarketDeliveryPackage
What the buyer’s node actually receives - the portable form of what market/delivery.ts would have forked into a local Studio. Only the kinds whose delivery is pure data cross a node boundary: a module is executable code that must meet the owner’s grant screen where it lands, a role/service raise local rows and runs, and hire cannot be sold at all.
| Field | Type | Notes |
|---|---|---|
kind | 'generator' | 'skill' | 'theme' | |
generator | { name: string (max 120); description: string (max 4000); icon: string (max 16); inputsSchema: unknown; steps: unknown[]; outputKinds: string (max 40)[] } | null | optional · default null |
skillName | string (max 120) | null | optional · default null |
promotion | { sourceGeneratorId: string (max 120); capabilityId: string (max 120); promotedAt: string (max 40); runs: number (safeint, ≥ 0); successes: number (safeint, ≥ 0); steps: { stepId, templateDigest, holes, provenRuns, from, to }[] } | null | optional · default null |
theme | { name: string (max 120); background: unknown; palette: unknown; glass: unknown; motion: string (max 40); fontDisplay: string (max 80); fontBody: string (max 80) } | null | optional · default null |
MarketDelivery
The delivery, signed by the HALL’s node key. The buyer’s node verifies signature.did is the Hall it was talking to, and that orderId/requestDigest match the buy it just initiated - that is what “corresponds to an order it initiated” means in code.
| Field | Type | Notes |
|---|---|---|
v | 1 | |
type | 'novaterra.market.delivery' | |
did | string (regex) | |
orderId | Id | |
listingId | Id | |
requestDigest | string (max 64) | |
package | MarketDeliveryPackage | |
issuedAt | ISODate |
SignedMarketDelivery
| Field | Type | Notes |
|---|---|---|
payload | MarketDelivery | |
signature | { alg: 'ed25519'; did: string (regex); sig: string (regex) } |
MarketBuyResult
The answer to a buy or redeliver. Always HTTP 200, refusal included (the federation posture).
| Field | Type | Notes |
|---|---|---|
accepted | boolean | |
code | 'malformed' | 'node-unverified' | 'unbound-node' | 'stale' | 'no-listing' | 'not-deliverable' | 'already-owned' | 'insufficient-credits' | 'own-listing' | 'no-order' | null | optional · default null |
reason | string (max 400) | optional · default '' |
order | Order | null | optional · default null |
delivery | SignedMarketDelivery | null | optional · default null |
RemoteOrder
The node’s local record of a thing it bought from the Hall. Keeps re-delivery possible.
| Field | Type | Notes |
|---|---|---|
id | Id | |
listingId | Id | |
listingTitle | string | optional · default '' |
hallDid | string (regex) | |
kind | 'generator' | 'skill' | 'theme' | |
orderId | Id | |
credits | number (safeint, ≥ 0) | |
deliveredLocalId | string | null | optional · default null |
status | 'delivered' | 'import-failed' | |
createdAt | ISODate |
RemoteBuyRequest
POST /api/market/remote/buy - the node’s owner buys a Hall listing. Body carries only the id.
| Field | Type | Notes |
|---|---|---|
listingId | Id |
RemoteMarketView
GET /api/market/remote - the node’s market view: the Hall’s public listings (a federated read), plainly said to spend credits held on the Hall, and what this node has already bought.
| Field | Type | Notes |
|---|---|---|
configured | boolean | |
hallEndpoint | string (max 300) | optional · default '' |
hallDid | string (regex) | null | optional · default null |
linked | boolean | |
headline | string (max 300) | optional · default '' |
listings | Listing[] | optional · default [] |
orders | RemoteOrder[] | optional · default [] |
lastError | string (max 400) | optional · default '' |