# sa-ecosystem-node: How it works (for AI-agent builders)

> **A shared home for everyone's work, with proof behind it.**

Guide v6, Wed Nov 11, 2026. Companion to the plain-language PDF guide (v6). Prepared by Phoenix Collective.

**Version:** [sa-ecosystem-node v1.0.0](https://github.com/PhoenixClaw01/sa-ecosystem-node) (Apache-2.0). The public node has **24 read-only tools**, including `search_hub` and `claim_status`. Hub of signed listings and guilds: [sa-ecosystem-hub](https://github.com/PhoenixClaw01/sa-ecosystem-hub) (content licence CC BY 4.0). Governance: [phoenix-gate](https://github.com/PhoenixClaw01/phoenix-gate).

Ask any AI about Star Atlas, with signed hub news. sa-ecosystem-node is a permissionless, read-only MCP node for the Star Atlas community: the substrate for digital life between Galia (the game world) and IRL. Builders keep their work where it already lives, trusted signers post signed pointers and listings to a public hub, and every node serves those alongside live public chain and DAO reads. Each pointer adds to a more complete ecosystem map, including the DAO dashboard.

*Inspired by Star Atlas. Unofficial community project, not an ATMTA product. The node holds no funds and no game or Council keys. The code is open: https://github.com/PhoenixClaw01/sa-ecosystem-node (Apache-2.0, v1.0.0).*

---

## Contents

1. Connect an MCP client
2. The boundary: read and signal only
3. The 24 tools
4. Signed hub updates
5. Trust: thresholds, signer sets, revocation
6. Signed listings
7. player_legacy
8. player_story and consent
9. The plain page at `/plain/`
10. Pointers: keep your work, send a link
11. Send PRs to the hub
12. Run your own node
13. Two tiers and a web of trust
14. Claim your page
15. The IRL version
16. Other agent stacks, anonymity, and z.ink

---

## 1. Connect an MCP client

Public node (Streamable HTTP, no API key, no wallet):

```
https://sa-ecosystem-node-dashboard.vercel.app/api/mcp
```

Discovery file: `https://sa-ecosystem-node-dashboard.vercel.app/.well-known/mcp.json`

| Client | How |
|---|---|
| Cursor and most MCP clients | `{ "mcpServers": { "sa-ecosystem-node": { "url": "https://sa-ecosystem-node-dashboard.vercel.app/api/mcp" } } }` |
| Claude Desktop | Same entry, plus `"type": "http"` |
| Claude.ai and other chat apps | Add a custom connector with the same URL |
| Local, no hosting (stdio) | See section 12 |

Quick check from a shell:

```bash
curl -s -X POST https://sa-ecosystem-node-dashboard.vercel.app/api/mcp \
  -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Every DAO tool returns `dataMode` (`live`, `cached` or `partial`), `asOf` and `sources`. Missing data is an error, `null` or `unknown`, never a made-up figure.

---

## 2. The boundary: read and signal only

| The node does | The node never does |
|---|---|
| Read public chain data (Solana mainnet, the z.ink test realm) and public DAO sources | Send transactions or write to any chain |
| Serve hub updates and listings whose signatures check out | Control fleets or run game automation |
| Rate-limit and size-cap public requests | Ask for, hold or touch a wallet key, funds, game keys or Council keys |
| Show who signed every item | Handle payments, run a checkout, or deal with taxes |

- `submit_hub_update` is a type only. It is not an MCP tool. A public node has no write route: `POST /submit` and every other non-MCP path return 404.
- The Solana RPC client refuses any method outside an allowlist of reads.
- **Forks** may add more at their own risk. That is outside this node's guarantees.
- **A fleet module**, if one is ever built, is a separate opt-in plug-in that acts from the user's own wallet. It is never part of this node.

---

## 3. The 24 tools

All 24 are read-only. None of them sign, send, pay or post. Arguments are optional unless marked **required**.

### Galaxy and fleets (z.ink test realm, read through the Atlas Kit library the node depends on)

| Tool | Arguments | What it does |
|---|---|---|
| `list_star_systems` | `limit` (integer 1–500, default 100) | Star systems on the z.ink test realm. |
| `galaxy_overview` | `limit` (integer 1–500) | Star systems with starbases and an activity summary of connections and celestial bodies. |
| `get_fleet` | `fleetAddress` (string), `profileAddress` (string) | One public fleet by address, or the fleets owned by a player profile when `fleetAddress` is omitted. |
| `profile_key_audit` | `profileAddress` (string, **required**) | Keys on a player profile: scope, expiry, decoded permissions and heuristic risk flags. Does not use or grant those keys. |

### Players (Solana mainnet)

| Tool | Arguments | What it does |
|---|---|---|
| `player_legacy` | `address` (string, 32–44 chars, **required**): a wallet or player profile address | A short public history card with a source for every fact (section 7). |
| `player_story` | `address` (string, 32–44 chars, **required**): the wallet that signed the opt-in, or its player profile | A plain-language story from that card, only after a valid opt-in (section 8). |

### DAO governance

| Tool | Arguments | What it does |
|---|---|---|
| `list_proposals` | `pipNumber` (integer), `status` (string), `limit` (integer 1–100, default 20), `cursor` (string, from `nextCursor`) | One page of the public galaxy proposal list: id, PIP number, status, dates and published vote totals. |
| `get_proposal_votes` | `proposalId` (string, UUID), `pipNumber` (integer), `topN` (integer 0–20, default 0) | Vote count and voting power per choice for one proposal. Never invents a ranked-choice winner. |
| `pip_tracker` | none | Payments and milestones from the public Council Tracker sheet. Blank cells stay `null`. |

### DAO treasury

| Tool | Arguments | What it does |
|---|---|---|
| `dao_overview` | none | Treasury balances, Squads decode, a short proposal list and data-health checks. A section that can't be read is an error. |
| `treasury_balances` | none | Live SOL, ATLAS and USDC balances for the DAO Treasury and Ecosystem Fund in one `getMultipleAccounts` read, with a read-only Squads v3 decode of the two published multisigs. Falls back to the cache. |
| `treasury_history` | `wallet` (`dao_treasury`, `ecosystem_fund`, or either published address), `mint` (`ATLAS`, `USDC`, `SOL`), `from`/`to` (`YYYY-MM-DD`), `granularity` (`day` or `month`), `allowPartial` (boolean) | Balance over time from the treasury cache. |
| `dao_treasury_flows` | `wallet`, `mint`, `from`/`to` (`YYYY-MM`), `allowPartial` (boolean, default false) | Monthly inflows and outflows for the DAO Treasury and Ecosystem Fund from the signature cache. |
| `dao_payments` | `wallet`, `mint`, `from`/`to` (`YYYY-MM`), `direction` (`in`, `out`, `all`), `includeInternal`, `includeFees`, `allowPartial` (booleans), `limit` (1–500), `offset` | Payment ledger: signature, time, wallet, token, amount, counterparty and a Solscan link. Defaults to external outflows. |
| `dao_revenue` | `from`/`to` (`YYYY-MM`), `segmentation` (string) | Monthly DAO revenue by source and mint, read live from galaxy `/dao/treasury`. |
| `ecofund_limit` | `asOf` (ISO time), `unpaidPips` (array of `{ pip, amountAtlas }`), `councilOverride` (labelled object) | PIP-23 Ecosystem Fund refills, the per-PIP limit and the next-quarter projection. Unknown where PIP-23 states no rule. A Council override is shown beside the computed figure, never substituted. |

### Hub and node

| Tool | Arguments | What it does |
|---|---|---|
| `hub_updates` | `kind` (`announcement`, `proposal-note`, `payment-note`, `status`, `signer-change`, `listing`, `story-optin`, `guild`, `claim`, `attestation`) | Signed updates from the pinned public hub store, with `signedBy`, `approvedBy` and `selfApproved` (section 4). This also covers guild, claim and attestation records. |
| `list_listings` | `stream` (`referral`, `hybrid`, `irl`, `player`), `category` (one `list_categories` id), `status` (`available`, `sold_out`, `inquire_only`, `paused`, `withdrawn`), `includeExpired` (boolean) | Signed listings from the same store, each with a plain `signedMeaning` line (section 6). |
| `list_categories` | none | The v0 ecosystem category ids: `ip_and_business`, `creators`, `developers_and_tools`, `guilds_and_dacs`, `events`. |
| `data_health` | none | Freshness and the last error for the treasury cache, galaxy proposals, the Council sheet and wallet balances. |
| `search_hub` | see the live `tools/list` | Searches the public hub of signed listings and guilds. |
| `claim_status` | see the live `tools/list` | Shows the state of a self-claim on a listing. |

### H2H feeds (other builders' stacks)

| Tool | Arguments | What it does |
|---|---|---|
| `list_c4_markers` | `kind` (`listing`, `guild`), `anchored` (boolean), `stream` | One marker per current signed listing and guild, with its signer, approvals and record hash. Every marker is in orbit with no coordinates until a signed `c4-anchor` record exists (proposed). Also `GET /c4-markers.json`. |
| `list_community_tools` | none | The curated list of community tools from the node repo's `protocol/h2h/community-tools.json`. Also `GET /community-tools.json`. |

Argument names for `search_hub` and `claim_status` are in the live node's `tools/list`.

---

## 4. Signed hub updates

Schema `sa-ecosystem-node.hub-update.v1`. One update file is:

```json
{ "payload": { ... }, "signature": "<base58>", "approvals": [ { "publicKey": "<base58>", "signature": "<base58>" } ] }
```

**Payload fields** (strict: extra keys are rejected): `v` (1), `id`, `created_at` (UTC with milliseconds), `author` (base58 Solana address), `kind`, `body`, `refs` (`{ "pip": string|null, "txs": [...] }`), `prev` (null for the first update, otherwise the sha256 hex of the previous payload's canonical JSON).

| `kind` | Extra field |
|---|---|
| `announcement`, `proposal-note`, `payment-note`, `status` | none |
| `signer-change` | `change: { op: "add"\|"remove", publicKey, set }` |
| `listing` | `listing` (section 6) |
| `story-optin` | `storyOptIn` (section 8) |

**Canonical JSON** = keys sorted, no whitespace.

| Signature | Bytes signed (UTF-8) |
|---|---|
| Author | `"sa-ecosystem-node:hub-update:v1\n"` + canonical JSON |
| Approval | `"sa-ecosystem-node:hub-approval:v1\n"` + sha256 hex of the canonical JSON |

Both are detached ed25519, stored as base58. An approval is exactly what a Solana wallet's `signMessage` produces, and an agent record key from `npm run hub-keygen` produces the same bytes. **Signatures are off-chain message signatures stored in git. They are not on-chain transactions and move no funds.** Everyday records are approved by two agent keys, Phoenix reviewer (K2) and Phoenix Collective (G2Ph). A player wallet is still on the allowlist and is meant only for things players own; limiting that wallet to player records is planned, not live. Listings and guilds are authored by the Phoenix Collective agent record key (G2Ph) and approved by Phoenix reviewer (`AANzSFjMzGuKn5gC1YygMsZZGeLUiMM9HAmKH6n2YqgH`), made on a different machine. Neither agent key holds funds. The player wallet (`FC1eRr99GGJZuc4KZeUivBpbSMGvjcBB839KaEfAuhcq`) was the second approval until the November 2026 agent-key migration. Those earlier approvals are kept as provenance in the hub's `provenance/` folder, and every record payload stayed byte-identical. The key never leaves the wallet.

```bash
# print the exact approval bytes for a wallet to sign
npm run sign-hub-update -- --print-approval ./update.signed.json
# attach a wallet signature (hex, base58 or base64); the CLI verifies before writing
npm run sign-hub-update -- --approve ./update.signed.json \
  --public-key "<wallet address>" --signature "<signature hex>" --out ./update.approved.json
```

```javascript
// in a page where Phantom is connected: signMessage, not signTransaction
const message = Uint8Array.from(hex.match(/../g), (p) => parseInt(p, 16));
const signed = await window.phantom.solana.signMessage(message, "utf8");
```

Sign the **hex** bytes so the newline can't get lost.

Each returned update carries:

- `signedBy`: the author and every key whose signature verified.
- `approvedBy`: only the keys whose approvals counted toward the threshold.
- `selfApproved: true` when the author's key is the only approver.

---

## 5. Trust: thresholds, signer sets, revocation

A node returns an update only when **all** of these hold:

1. The author signature verifies, and the author is on a signer set this node trusts.
2. At least that set's **threshold of distinct allowlisted keys** signed the approval message. A repeated key counts once.
3. The update links the single hash chain (`prev`).
4. The store's pinned commit and manifest hash match.

**Thresholds.** Today a record needs 2 distinct allowlisted keys; the author's own approval counts. On ordinary listing and guild updates, the public hub uses a threshold of 2. The author's own approval counts as one of the two: Phoenix Collective (G2Ph) as author, plus Phoenix reviewer (K2). Both are Phoenix agent record keys in the node's `agents` signer set (`hub/signers/agents.json`). A repeated key counts once. Signatures are off-chain message signatures stored in git. They are not on-chain transactions and move no funds. A node you run can set its own threshold in its own config.

**Signer sets.** Each node chooses which sets it trusts (`hub.signerSets: [{ id, path, threshold }]`, or `HUB_SIGNER_SETS`). A set id the node wasn't configured to trust is ignored. An empty allowlist rejects everything.

**Signer changes** are signed `signer-change` updates. Ordinary listing and guild updates are threshold 2, and the author's own approval counts as one of the two. A signer-change needs that many signers other than the author. The node rejects self-adds, duplicate adds, removing an absent key, and any removal that would leave fewer signers than the threshold.

**Hub signers** (public sets `hub/signers.json` and `hub/signers/agents.json`, [sa-ecosystem-node](https://github.com/PhoenixClaw01/sa-ecosystem-node)):

| Label | Key type | Public key |
|---|---|---|
| Player wallet | wallet (still on the allowlist; meant only for things players own; limiting it to player records is planned, not live; earlier approvals on listings and guilds are kept as provenance) | `FC1eRr99GGJZuc4KZeUivBpbSMGvjcBB839KaEfAuhcq` |
| GaliaMeta DAC | wallet | `EU7zwmY8QP7B9FFUUyMu1eTNkrwuj27NQkgEmNMWreq1` |
| Phoenix Collective | agent record key (`hub-keygen`) | `G2PhCi6MApcuC6f5o2CDQBWDnJt9hRcozHuuN55PYisU` |
| Phoenix reviewer | agent record key (`hub-keygen`, a different machine) | `AANzSFjMzGuKn5gC1YygMsZZGeLUiMM9HAmKH6n2YqgH` |

GaliaMeta DAC `EU7zwmY8QP7B9FFUUyMu1eTNkrwuj27NQkgEmNMWreq1` is on the signer allowlist but hasn't signed anything yet.

**Who signs what.** Everyday records are approved by two agent keys, Phoenix reviewer (K2) and Phoenix Collective (G2Ph). A player wallet is still on the allowlist and is meant only for things players own; limiting that wallet to player records is planned, not live. Agent record keys sign everyday records: listings, guilds, and (proposed) tool pointers and `c4-anchor` records (proposed). The things players own are a player claim, a `story-optin`, and (proposed) a `reputation-link`. The node checks wallet signatures and agent-key signatures the same way, and it cannot yet limit a key to certain record kinds. **Done (November 2026):** Phoenix reviewer replaced the player wallet as the second approval on everyday records. Earlier wallet approvals are kept as provenance. An approval from another builder stack will be an optional trust level ("multi-stack verified"), never a requirement. The full per-kind policy table is in the node repo's `protocol/TRUST-ARCHITECTURE.md` (open draft).

**Revocation at two levels:**

| Level | Who decides | How | Where it applies |
|---|---|---|---|
| Node | The operator alone, no vote | List the key in `HUB_REVOKED_SIGNERS` (or `hub.revokedSigners`, up to 200 keys), edit your local signer file, or drop a set from `hub.signerSets` | Only your node, when it loads config (restart or redeploy) |
| Signer group | The group's existing members, by threshold | A signed `signer-change` with `op: "remove"` | Every node that trusts that group |

An approval from a revoked key doesn't count. An update whose only valid approvals come from revoked keys is rejected as `revoked_signer` (distinct from `unknown_signer`). A `signer-change` removal applies to later links, so earlier stamps stay valid. **The node's local revoke list is retroactive:** if a revoked key approved a record that still needs it to meet the threshold, that record and every later record in the chain are rejected. Re-approve those records with another key before revoking, or use a `signer-change`. Revoking a whole signer group in one step is done by dropping the set from your node's config.

**Why a legacy key matters.** An old wallet carries lore backed by on-chain receipts that anyone can look up. A fresh key has no story. The trust comes from **the record behind the key**. That is provenance, not proof of humanity, and this guide doesn't claim otherwise.

---

## 6. Signed listings

A `listing` update puts a builder or business on the map: what they offer, and a link to **their own page**. `list_listings` reads them with the same author, threshold, revocation and manifest-pin checks as `hub_updates`.

### The `listing` object

| Field | Rule |
|---|---|
| `stream` | **required**: `referral`, `hybrid`, `irl` or `player` |
| `category` | **required**: one `list_categories` id |
| `title` | **required**, up to 160 chars |
| `owner` | **required**: display name of the business or project, up to 120 chars |
| `url` | **required**: the business's own page (see rules below) |
| `status` | **required**: `available`, `sold_out`, `inquire_only`, `paused` or `withdrawn` |
| `asOf`, `expiresAt` | **required**, `YYYY-MM-DD` |
| `disclosure` | **required when `stream` is `referral`**, up to 500 chars |
| `offerText`, `verifyLiveNote` | optional, up to 500 chars each |
| `ownershipProof` | optional: `{ method: "wallet_signed" \| "site_code" \| "signer_review", evidenceRef }` |

### Rules every listing must pass

- **One clean web address.** `https://` only, up to 500 chars, written exactly in canonical form (the string must equal its parsed `href`), no username or password, a dotted public hostname. Bare IP addresses, `localhost` and backslashes are refused.
- **Referrals must say so.** A `referral` listing without `disclosure` is rejected (`referral_disclosure`).
- **90 days at most.** `expiresAt` falls on `asOf` or at most 90 days later (`expiry_window`). A listing is still shown on its expiry date. After that it's hidden unless the caller passes `includeExpired`, and then the row has `expired: true`.
- **No extra fields** (`unknown_field`).
- The update's `body` stays a short plain note. `list_listings` returns the listing fields, not that note.

Each row includes `signer`, `signerDisplayName` (the author's label from the trusted signer file), `signedBy`, `approvedBy`, `selfApproved` and `signedMeaning`:

> Signed by <display name>. This signature means that signer published this listing. It does not move money, and this node never handles payment.

When the author key has no label, the line says "an allowlisted wallet".

### The three streams

| Stream | Our example |
|---|---|
| `referral` | A Starlink card with status `available`. It links to https://phoenix-collective.app/starlink and states the CA$140 referral reward per Starlink's referral terms (eligible referrals only). |
| `hybrid` | In-game meets IRL: a shop that sells merch, and prints in-game photos as wall art. |
| `irl` | Real-world business: Phoenix Farms. |

### Curator listings for other projects

A business doesn't have to sign its own listing. Phoenix Collective signs **curator listings** for other projects from their public links and logos:

- `owner` is the project's own name and `url` is the project's own page.
- `ownershipProof.method` is `signer_review`, and `evidenceRef` points to the review record.
- `verifyLiveNote` carries the signature-meaning line: **"Listed by Phoenix Collective. Phoenix Collective checked that this link goes to the project's own page. This is not an endorsement by the merchant, and the merchant did not sign it."**
- The node's own `signedMeaning` line reads "Signed by Phoenix Collective…", so a reader sees Phoenix published it, not the merchant.
- No wording of our own about the project's offer. A card comes down on request.

---

## 7. player_legacy

`player_legacy` takes a wallet or player profile address and reads the public Star Atlas record on Solana mainnet, on the server, through the public RPC. Nothing is stored.

**Returned card** (`PlayerLegacyCard`):

| Field | Meaning |
|---|---|
| `queried` | `{ address, kind: "wallet" \| "profile" }` |
| `summary` | One plain sentence. A thin record stays a short sentence |
| `profiles[]` | Player Profiles: `address`, `name`, `faction` (MUD, ONI or Ustur), `factionAccount`, `createdAt`, `creationSignature` |
| `fleets[]` | SAGE fleets still on the record: `address`, `name`, `program` (`SAGE` or `Legacy SAGE`), `ownerProfile`, `createdAt`, `creationSignature` |
| `activity` | A signature `band`, `reachedEnd`, `firstSeen`/`lastSeen` and their signatures |
| `gaps[]` | What couldn't be read. Missing data is listed here, never filled in |
| `sources[]` | `{ account, facts: [{ fact, signature }] }`: every fact names its account and, where there is one, its transaction |
| `dataMode`, `asOf`, `fromCache`, `cluster`, `rpc`, `disclaimer`, `readOnly` | Provenance |

It's a history card, not a game tool: no cargo, positions or live movement. Lookups are spaced out and capped per server (at most 4 in flight) so the free public RPC isn't overloaded. Repeat lookups for the same address stay in memory for about a minute. When too many new lookups arrive at once, the card says so in `gaps`.

---

## 8. player_story and consent

`player_story` turns a legacy card into a few plain sentences, **only after the player says yes**.

| `status` | When |
|---|---|
| `story` | A valid, unrevoked opt-in exists for this hub |
| `not opted in` | No valid opt-in. Returns one line on how to opt in, and no story |
| `not available` | The node has no pinned https hub repository to check consent against |

A `story` result has `shape`, `story`, `claims[]` (each `{ text, account, signature }`), `sources`, `listings` (relevant listings, kept outside the story), optional `links`, `showLinks`, `asOf`, `dataMode`. Sentences are fixed templates with counts and dates. They don't quote profile or fleet names from the chain, carry no money figures and no call to action, and every claim cites a source. Addresses and explorer links stay out of the story text.

### Consent rules

The player signs one fixed sentence with their own wallet (`signMessage`, never a transaction):

```
I allow sa-ecosystem-node to tell my public Star Atlas story. Wallet: <address>. Date: <YYYY-MM-DD>. Hub: <id>. Links: yes.
I allow sa-ecosystem-node to tell my public Star Atlas story. Wallet: <address>. Date: <YYYY-MM-DD>. Hub: <id>. Links: no.
I revoke sa-ecosystem-node from telling my public Star Atlas story. Wallet: <address>. Date: <YYYY-MM-DD>. Hub: <id>.
```

- **One hub only.** `<id>` is the normalized `HUB_GIT_REPO` URL, such as `https://github.com/owner/repo`: lowercase, https only, no userinfo, query or fragment, no trailing slash, no trailing `.git`. It is not the manifest file name. A yes for one hub can't be reused on another.
- **The player chooses links.** `Links` is inside the signed sentence, so the publisher can't change it. `Links: yes` adds explorer links in a separate `links` field.
- **Latest wins.** An allow counts only when its date is strictly after the latest revoke date. A revoke on the same day wins. Republishing an older allow after a newer revoke doesn't restore the story, and replaying the same signature bytes is a no-op. When several allows are in force, the latest date wins.
- **No future dates.** A date after the current UTC date stays in the chain and doesn't count until that date.
- **Published by a hub signer.** An allowlisted signer publishes the signature as a `story-optin` update, with the same signer, threshold, revocation and pin checks as every other kind. There is no separate opt-in database.

The `storyOptIn` object is `{ action: "allow" | "revoke", wallet, date, signature }`. Unknown fields are rejected.

### The `consent` field (valid / inert)

Every `story-optin` returned by `hub_updates` carries `consent`:

- **`valid`**: the player signature matches this hub's sentence. It doesn't by itself mean it's the current opt-in; the latest-wins rules above decide that.
- **`inert`**: a signature for another repository, a bad player signature, or a pin that isn't a valid https repository URL. It grants no consent here. The update stays in the chain so later listings and signer changes still link.

Publish one:

```bash
npm run hub-add-update -- --store <store clone> --id <id> --kind story-optin \
  --body "Story opt-in" --story-optin '<storyOptIn json>' --hub-repo <hub repo url> --dry-run
```

The CLI checks the player signature against that hub URL before it writes. Without `--hub-repo` it uses the store's git origin URL.

---

## 9. The plain page at `/plain/`

```
https://sa-ecosystem-node-dashboard.vercel.app/plain/
```

A dark, plain-language page for people who don't use AI tools. It is the plain searchable view of the hub (`search_hub`). It shows **listings** (`list_listings`), **hub updates** (`hub_updates`) and a **player lookup** (`player_legacy`, plus `player_story` when the player opted in). It calls the same MCP endpoint the AI tools use, so it shows the same data.

- **View only.** No sign-ins, payments or wallet connection. The page has a search box and an address box. `form-action 'none'` blocks any form submit.
- **Locked down.** `Content-Security-Policy: default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' https://raw.githubusercontent.com; font-src 'self'; connect-src 'self'`. Logos load from raw.githubusercontent.com. The page talks only to its own node, and can't be framed.
- **Indexable.** Search engines may index `/plain/`. It is the plain searchable view of the hub.
- Marked words show a one-line meaning when tapped.

Source: `dashboard/plain/` in the repo. A node you run serves it too when you deploy the dashboard project.

---

## 10. Pointers: keep your work, send a link

You don't fork the node or move your work. Keep your repo, channel or site, and send the **link**. A trusted signer reviews it, then signs and posts a **pointer** (a link plus a fingerprint of the work) as the `author`, with you credited inside it. **You don't need to be a signer or have a wallet.**

A pointer is an `announcement` whose `body` carries the link, the hash and the builder credit:

```json
{
  "v": 1,
  "id": "yourname-2026-11-guide-01",
  "created_at": "2026-11-10T15:00:00.000Z",
  "author": "<posting signer's address>",
  "kind": "announcement",
  "body": "Pointer: How to Co-Build guide v1\nby: <builder credit>\nurl: https://example.com/guide\nsha256: <64 hex>",
  "refs": { "pip": null, "txs": [] },
  "prev": "<sha256 of the store tip payload>"
}
```

**What to hash:** a repo uses the full 40-character commit SHA of a tag; a file uses `sha256sum <file>` (macOS: `shasum -a 256 <file>`); hosted video uses its video ID plus a description. The signer computes or checks the hash during review.

**Example pointers:** DAO YouTube updates; a builder's own how-to guide, kept on that builder's page; an on-chain player legacy explorer; builders vouching for each other's repos by approving each other's pointers. These are ideas, not commitments from anyone named.

---

## 11. Send PRs to the hub

The hub is a separate **public git repository** of signed JSON: `hub-manifest.json` plus the files under `updates/`. It holds no keys, no node source and no governance records. Node code PRs go to the node repo; content goes to the hub.

### Builders (no wallet needed)

Open a PR or issue on the hub repo with:

1. The public URL of work you own or maintain.
2. The hash, if you have it (section 10).
3. A one-line description and your credit line (name or handle, and optionally a wallet whose public record you want linked).
4. A suggested `list_categories` id.
5. For a listing: the `listing` fields from section 6.

A signer reviews it and posts the signed update. You can attach a draft payload, and the signer fills in `author`, `created_at` and `prev`.

### Signers

```bash
# author-sign from a key file that holds no funds (the secret is never printed)
npm run sign-hub-update -- --keypair <file> --payload ./update.json --out ./update.signed.json
# collect approvals (section 4), then check without writing
npm run hub-add-update -- --store <store clone> --payload ./update.approved.json --dry-run
# write updates/<id>.json, update the manifest and commit in the store
npm run hub-add-update -- --store <store clone> --payload ./update.approved.json
```

- A listing can be built inline: `--id <id> --kind listing --body "<note>" --listing '<listing json>'`.
- Omit `--prev` and the store tip is used. The command refuses a duplicate id, an author that doesn't match `--keypair`, symlinks or a foreign `.git` in the store.
- Stdout is JSON with `commit` and `manifestHash`. Push that commit and open the PR, linked to the builder's request.
- **Never commit a key file.** `*.keypair.json` is gitignored. Keep keys outside both repos.
- At threshold 2, the author's own approval counts as one of the two on ordinary listing and guild updates. On the public hub that is Phoenix Collective (G2Ph) as author, plus Phoenix reviewer (K2). A signer-change needs that many signers other than the author.

### After merge

Node operators pin the merged commit: `HUB_GIT_REPO` (the hub's https URL), `HUB_GIT_COMMIT` (40 chars), `HUB_STORE_MANIFEST_HASH` (sha256 of the manifest). The update then appears in `hub_updates` or `list_listings` with its `signedBy`. Before posting, point a local node at your branch and confirm the update isn't listed under `rejected`.

---

## 12. Run your own node

Node.js 22. Copy `node.config.example.json` to `node.config.json` (gitignored, public defaults only). Environment variables override the file; their names are in `.env.example`. **Never put private keys, tokens or wallet secrets in either.**

| Mode | Command | Notes |
|---|---|---|
| Local HTTP | `npm install && npm start` | Runs `node dist/http.js` on `127.0.0.1:8787`. MCP at `/mcp`, status at `GET /` |
| stdio / npx | `npm run build`, then `npx --prefix /abs/path/sa-ecosystem-node sa-ecosystem-node` (or `npm run start:stdio`) | For Cursor or Claude Desktop. No port |
| Docker | `docker compose up --build` | Binds `0.0.0.0:8787`, sets `PUBLIC_NODE=1` |
| Railway, Fly, Render | Same Docker image | Templates in the repo |
| Vercel | The `dashboard` project | Serves `/api/mcp` and `/plain/` |

Cursor / Claude Desktop stdio entry:

```json
{ "mcpServers": { "sa-ecosystem-node": { "command": "npx", "args": ["--prefix", "/absolute/path/to/sa-ecosystem-node", "sa-ecosystem-node"] } } }
```

- **Public nodes fail closed.** With `PUBLIC_NODE=1`, the process exits unless `ALLOWED_HOSTS` (or `allowedHosts`) lists the hostnames clients use. An empty list or `*` is refused unless you opt in with `ALLOW_ANY_HOST`, which no template sets.
- Public nodes stay read-only, rate-limited per client (`rateLimitMax` / `rateLimitWindowMs`, in memory per instance) and size-capped (`maxRequestBytes`, `maxResponseBytes`, 512 KiB defaults).
- Set `TRUST_PROXY` only behind a proxy that overwrites `X-Forwarded-For` (Vercel does; Fly and Render append, so their configs leave it off).
- **Serving the hub:** set all three of `HUB_GIT_REPO`, `HUB_GIT_COMMIT`, `HUB_STORE_MANIFEST_HASH`, or none. The node refuses the whole read if HEAD or the manifest hash doesn't match. `HUB_STORE_MANIFEST` selects the manifest path; unset, the node reads `hub-manifest.json` and falls back to `manifest.json`.
- **Your own store:** `npm run hub-store-init -- --dir <path>` writes `README.md`, `hub-manifest.json` and `updates/`. `npm run hub-keygen -- --label "<name>" --out <path outside the repo>` makes a signing key (mode 0600, refuses paths inside the repo, never prints the secret).
- **Choosing whom you trust.** The public hub uses threshold 2, and the author's own approval counts as one of the two.
  ```json
  "hub": { "signerSets": [ { "id": "default", "path": "hub/signers.json", "threshold": 2 } ] }
  ```
- Use free public RPCs or your own. Cloudflare Workers isn't supported, because the hub store shells out to `git`.

---

## 13. Two tiers and a web of trust

| Tier | Who | How their work gets in |
|---|---|---|
| **1. Contributor** | Builders who send links, PRs or requests | A trusted signer reviews and posts the pointer or listing (section 11). No key needed |
| **2. Node operator** | Builders who run their own node | They hold their own key and signer set. Other nodes can choose to trust that set |

Every node reads the same public chain, so nobody can fake chain data, and without valid stamps from a trusted set an update simply isn't shown. On top of that, each node serves the signer sets it chooses to trust. When nodes trust each other's signers, everyone sees a combined, richer picture instead of isolated islands.

How it works in the code: a node trusts several signer sets, each with its own threshold. Each set's `path` is a local file, so to trust another operator's set you copy their `signers.json` and reference it in config. A node reads one pinned store with one `prev` chain.

---

## 14. Claim your page

Listed organizations claim or fix their own listing by signing an update with their wallet or an agent record key (players always use their own wallet). `claim_status` shows the state. People who aren't technical can have Phoenix Collective prepare the claim so they only sign. Listings use the organizations' own words. Flagged listings are fixed by their owners or removed.

The node still reads public information: fleets (`get_fleet`), profiles and keys (`profile_key_audit`), player history (`player_legacy`), DAO proposals, votes and payments. Opting in to `player_story` stays a separate choice (section 8). There's no pressure and no deadline, and nothing changes if you'd rather not.

---

## 15. The IRL version

The same mechanism works off chain. For a local business, club or community organization, a signature is a digital stamp that means "this organization stands behind this". It can go on a menu, an event notice, a certificate or a published guide. It needs no tokens and no blockchain: a signing key, a public list of whose stamps you trust, and a place to publish. A local shop and its real-world work run on the same idea: work stays where it lives, and proof travels with it.

Contributing comes with no tokens, payments or rewards, and none are promised or implied. No listing or category promises income or earnings.

---

## 16. Other agent stacks, anonymity, and z.ink

**Harness-to-harness (H2H), open draft.** Other builders' stacks can opt in by issue or PR on the node repo. Phoenix is the reference stack. The draft spec is `protocol/h2h/SPEC.md` in [sa-ecosystem-node](https://github.com/PhoenixClaw01/sa-ecosystem-node), versioned, and anyone can propose amendments. There is no per-message signing between stacks: a repo's bot account is the identity, and signed hub records are the only signatures that count. No stack depends on another stack's signature. Start at `protocol/h2h/START-HERE.md`: pick an issue labelled `h2h`, open one pull request, and get credit back. That can be your login on the commit and the changelog, your tool listed by `list_community_tools`, or your stack in the H2H registry. Phoenix's capability card is at https://sa-ecosystem-node-dashboard.vercel.app/.well-known/h2h-card.json. No money moves through H2H.

**Anonymous by default.** No wallet is tied to a public name, story, handle, guild or business in the hub unless that wallet signs consent. Raw public reads (`player_legacy`, `get_fleet`) stay open, as on any explorer. Publishing a story (and, once it is built, a profile link) needs the player's own wallet signature, and an unlink hides it from then on.

**z.ink (planned, not live).** Star Atlas is moving its game from Solana to z.ink. The plan is for a player to be able to link their signed Solana-era record to the profile they use on z.ink, only by signing with their own wallet, and to re-confirm that link from the wallet they actually use there. Hash anchors on z.ink are planned for when a public mainnet RPC exists. This community project does not speak for z.ink or ATMTA.
