@fangorn-network/westmarch 0.0.1-dev
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
'Software'), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# @fangorn/westmarch
|
|
2
|
+
|
|
3
|
+
Everything you need to get started building apps with Fangorn.
|
|
4
|
+
|
|
5
|
+
**Start here:** [docs/app-to-agent.md](docs/app-to-agent.md) takes a static site from nothing
|
|
6
|
+
to a Fangorn app and ERC-8004 agent that any agent can discover, verify and drive.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
**For Publishers** — shape a tree into a commit graph, describe what's in it, seal and upload the bytes. Needs `fangorn`. Runs anywhere: a script, a server, a browser.
|
|
10
|
+
|
|
11
|
+
**For Consumers** — read a quickbeam view's Semantic CDN shards in the reader's own process and rank them there. Needs a view URL and nothing else. No index server, no chain read, and the query never leaves the tab.
|
|
12
|
+
|
|
13
|
+
The two halves never talk to each other. A publisher writes a graph; some
|
|
14
|
+
watcher bakes it into a view; a consumer reads the view. Either half is useful
|
|
15
|
+
without the other.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
publish/ consume/
|
|
19
|
+
graph.js fs tree ⇄ vertices + edges directory.js WHICH corpus
|
|
20
|
+
enrich.js what a file says → search text shard.js view → rows, streamed
|
|
21
|
+
envelope.js chunk keys, AEAD, upload/delete roles.js which field is the title
|
|
22
|
+
lint.js …and whether any of that WORKED taste.js what YOU like, portably
|
|
23
|
+
rank.js rows + query → hits
|
|
24
|
+
embed.js text → 256-d vector
|
|
25
|
+
apps.js every app bound on chain
|
|
26
|
+
corpora.js SEVERAL corpora, open at once
|
|
27
|
+
ui.js previews, from the role_map
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`lint.js` sits in the consume/ directory and reads like a consumer, but it is
|
|
31
|
+
for the person who baked the view: every capability on the right is paid for at
|
|
32
|
+
bake time on the left, and the bill is invisible until a reader hits it.
|
|
33
|
+
|
|
34
|
+
## lint.js — what will the index be able to do with this?
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
$ node consume/lint.js https://cdn.example/q/qb_1
|
|
38
|
+
archive-transcripts — 21,131 rows — 1 thing stopping readers finding or reading this
|
|
39
|
+
✗ paywall names 1 field the free shard ships anyway
|
|
40
|
+
text — readers are told these cost money and are handed them for free.
|
|
41
|
+
→ Either withhold them from the shard, or take them out of paywall.locked.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Three levels because there are three real outcomes: the reader cannot find you
|
|
45
|
+
(no `coverage`, a foreign embedding model, no vectors), they find you and cannot
|
|
46
|
+
read you (no `role_map`, no text role), or they read you and cannot go anywhere
|
|
47
|
+
(no `externalUrl`, no `actions`, no `launch`).
|
|
48
|
+
|
|
49
|
+
The check that needs a real shard — and the reason this is not a schema
|
|
50
|
+
validator — is a `paywall` naming fields the free shard ships anyway. It found
|
|
51
|
+
that in this repo's own fixtures on its first run, twice.
|
|
52
|
+
|
|
53
|
+
## corpora.js — the questions one publisher cannot answer
|
|
54
|
+
|
|
55
|
+
`directory.js` picks the right corpus. This holds several of them open, which is
|
|
56
|
+
a different thing and the more useful one: rank *these games* by a taste built
|
|
57
|
+
from *those films*, put one query to four publishers and get one ranking back.
|
|
58
|
+
|
|
59
|
+
`shard.js` always cached rows per view — what was singular was the consumer's
|
|
60
|
+
idea of "the" corpus, and the two `configure()` hooks, which now say which view
|
|
61
|
+
they are running for. Without that, the second corpus loaded is silently parsed
|
|
62
|
+
with the first one's text role.
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
const S = session({ blankRoles: () => rolesFrom([], []) });
|
|
66
|
+
configure({
|
|
67
|
+
onManifests: (ms, view) => { S.slot(view).roles = rolesFrom(ms); },
|
|
68
|
+
rowText: (f, view) => textOf(f, S.slot(view).roles),
|
|
69
|
+
});
|
|
70
|
+
// …then merge() collapses per-corpus hit lists onto one scale. It is a sort,
|
|
71
|
+
// not a normalisation: every corpus scored the same query vector.
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`example/cross.mjs` runs it against the four fixture bundles.
|
|
75
|
+
|
|
76
|
+
## ui.js — an interface for data nobody wrote an interface for
|
|
77
|
+
|
|
78
|
+
A tool can return an HTML view beside its JSON (MCP Apps, formerly mcp-ui): an
|
|
79
|
+
`EmbeddedResource` with a `ui://` uri, rendered by a host that understands it and
|
|
80
|
+
ignored by one that does not. `resource` is a standard MCP content block, so
|
|
81
|
+
emitting one is safe everywhere.
|
|
82
|
+
|
|
83
|
+
It is worth doing *here* because this page does not know what its rows are. The
|
|
84
|
+
publisher's `role_map` already states which field is the title, the subtitle, the
|
|
85
|
+
tags, the measures — which is exactly a table renderer's input. So the interface
|
|
86
|
+
is generated per corpus at the moment of the call, from a declaration that
|
|
87
|
+
shipped with the data.
|
|
88
|
+
|
|
89
|
+
## taste.js — the thing one shared embedding space actually buys
|
|
90
|
+
|
|
91
|
+
The directory refuses to rank a corpus baked with a different model, because
|
|
92
|
+
cosine between two models' vectors is noise. Stated that way it sounds like a
|
|
93
|
+
limitation. It is the opposite:
|
|
94
|
+
|
|
95
|
+
> a taste learned in ONE publisher's corpus applies directly to ANOTHER'S.
|
|
96
|
+
|
|
97
|
+
Not by matching ids — there are none in common. Not by a shared schema — there
|
|
98
|
+
isn't one. Measured on this repo's own corpora: four bleak sci-fi films picked
|
|
99
|
+
out of 20,986 archive films produce a 888-byte vector which, pointed at 1,571
|
|
100
|
+
Wikipedia video games from an unrelated publisher, returns Beam Breakers
|
|
101
|
+
(cyberpunk), B-Movie and Crimson Tears. Four comedies produce a vector that
|
|
102
|
+
returns Bugs Bunny, Elmo in Grouchland and Goofy Skateboarding. Same corpus,
|
|
103
|
+
same code, disjoint answers.
|
|
104
|
+
|
|
105
|
+
Every recommender that exists keeps its embedding private, which is exactly what
|
|
106
|
+
makes taste non-portable — your taste is an asset of whoever holds the model.
|
|
107
|
+
Here the model is public and the vectors are in files you already downloaded, so
|
|
108
|
+
the taste is a small object you own, carry, read and correct.
|
|
109
|
+
|
|
110
|
+
State is sond3r's session kernel cut to what travels: `mu` (where you are), `v`
|
|
111
|
+
(where you're heading), `q` (the lookahead you rank with), `no` (what you
|
|
112
|
+
rejected, with the same GAMMA the kernel uses, so a rejection means the same
|
|
113
|
+
thing on both surfaces). `exportTaste` packs it as base64 int8 and carries the
|
|
114
|
+
TITLES it was built from — a taste you cannot read is a taste you cannot
|
|
115
|
+
correct, and this is meant to be a thing a person owns rather than a profile
|
|
116
|
+
held about them.
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
node example/taste-demo.mjs # films -> games, end to end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## steam.js — the cold start, solved by a file you already have
|
|
123
|
+
|
|
124
|
+
A taste needs picks and a new reader has none, so every recommender opens by
|
|
125
|
+
asking you to rate ten things. The obvious fix for games is Steam's Web API:
|
|
126
|
+
issue a key, call GetOwnedGames, receive the library. It works, and it means the
|
|
127
|
+
first thing a recommender does — before recommending anything — is tell Valve
|
|
128
|
+
that you asked.
|
|
129
|
+
|
|
130
|
+
Steam already wrote your playtime to your own disk. `localconfig.vdf` holds one
|
|
131
|
+
entry per app you have launched, with minutes played and a last-played
|
|
132
|
+
timestamp. No key, no login, no network, and reading it is not a request anyone
|
|
133
|
+
can log. `steamLibrary(text)` parses it, `seedTaste(library, rows)` matches it
|
|
134
|
+
against a corpus on `appid`, and what comes back drives `taste.js` unchanged.
|
|
135
|
+
|
|
136
|
+
The mapping is where the judgement is, and it is deliberately narrow:
|
|
137
|
+
|
|
138
|
+
| in the library | becomes | why |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| played a lot | a like | you kept coming back |
|
|
141
|
+
| launched, bounced off | a dislike | you tried it and stopped — the only negative signal a library actually contains |
|
|
142
|
+
| never launched | **neither** | a backlog is not a rejection |
|
|
143
|
+
|
|
144
|
+
That third row is the one that matters. `taste.js` applies GAMMA 0.6 to
|
|
145
|
+
rejections, so a wrong dislike actively steers recommendations away from things
|
|
146
|
+
you would have liked, and an unplayed game in a library is a sale or a bundle
|
|
147
|
+
rather than a verdict. Playtime decides membership; `LastPlayed` decides order,
|
|
148
|
+
because the half-life is what makes a taste steerable and sorting by hours would
|
|
149
|
+
tell it that a game you adored in 2021 is where you are now.
|
|
150
|
+
|
|
151
|
+
In a browser it is a drop target — parsed in the tab, going nowhere. In Node it
|
|
152
|
+
is a path. Same parser either way.
|
|
153
|
+
|
|
154
|
+
## Sharing back — what a publisher can learn, and what it cannot
|
|
155
|
+
|
|
156
|
+
Three modules make the reverse direction honest, and until now none of them was
|
|
157
|
+
reachable from an agent:
|
|
158
|
+
|
|
159
|
+
- **`reactions.js`** turns a reader's log into a corpus someone can buy. The free
|
|
160
|
+
index carries coverage centroids, which corpus, and the month — the *shape* of
|
|
161
|
+
someone's attention. Which item, what it was called, and which way they reacted
|
|
162
|
+
are the paid columns, and no reaction ships a vector, because a reaction's
|
|
163
|
+
vector is a copy of a row anyone can download free.
|
|
164
|
+
- **`cohort.js`** answers a publisher's question by pairwise-masked secure
|
|
165
|
+
aggregation. Every reader evaluates the question against their own log and
|
|
166
|
+
returns a share that is uniform over the ring; sum the cohort and the masks
|
|
167
|
+
cancel. Below five readers it refuses, because an aggregate over two people is
|
|
168
|
+
two people's data with a total sign on it.
|
|
169
|
+
- **`demand.js`** names the resulting direction in the publisher's own declared
|
|
170
|
+
tag vocabulary, measures what their shelf holds along it in the shelf's own
|
|
171
|
+
sigma, and returns a verdict: commission, serve, fix, retire, cold.
|
|
172
|
+
|
|
173
|
+
`example/main.js` exposes the reader's half as `share-reactions` and
|
|
174
|
+
`answer-question`; `example/publisher-console.mjs` is the buyer's half.
|
|
175
|
+
|
|
176
|
+
## directory.js — searching FOR data, before searching IN it
|
|
177
|
+
|
|
178
|
+
Every other module answers a question about rows you already have. An agent
|
|
179
|
+
facing a network of independent publishers has an earlier question: *which
|
|
180
|
+
corpus*.
|
|
181
|
+
|
|
182
|
+
The bake already publishes the answer. Each domain's `catalog.json` carries
|
|
183
|
+
`coverage` — spherical k-means centroids over a sample of its own vectors, 8 at
|
|
184
|
+
128 dims, about 4 KB. Rank a query against every publisher's centroids and you
|
|
185
|
+
have ranked the network without downloading a shard.
|
|
186
|
+
|
|
187
|
+
That is what keeps it from being a search engine. A directory that indexed rows
|
|
188
|
+
would have to hold everyone's rows. This holds 4 KB per corpus and never sees
|
|
189
|
+
one — and the query is embedded in the client, so it never sees the question
|
|
190
|
+
either. Same privacy property the shards give search, one level up.
|
|
191
|
+
|
|
192
|
+
`findCorpora(query, {sources, embed, model})` returns matches with affinity,
|
|
193
|
+
size and price, plus what could not be reached and what could not be compared —
|
|
194
|
+
a corpus baked with a different embedding model is excluded and named, because
|
|
195
|
+
cosine between two models' vectors is noise that still looks like a score.
|
|
196
|
+
|
|
197
|
+
## roles.js — why a reader works on a corpus nobody wrote code for
|
|
198
|
+
|
|
199
|
+
A generic reader has one hard problem: it has rows and no idea which field is
|
|
200
|
+
the title. Hardcode `name` and it works on one bundle and returns `undefined` on
|
|
201
|
+
the next.
|
|
202
|
+
|
|
203
|
+
The answer is already baked. A quickbeam domain manifest carries `role_map`
|
|
204
|
+
(title, subtitle, tags, the prose to search, measures, location, link) and
|
|
205
|
+
`presentation` (icon, accent and singular/plural per entity type, plus per-type
|
|
206
|
+
external URL templates). `rolesFrom(manifests, sample)` merges that across the
|
|
207
|
+
domains a view fuses and falls back to sniffing where a domain declared nothing.
|
|
208
|
+
`collections(roles, rows)` turns `entity_types` into browsable groups with the
|
|
209
|
+
publisher's own icons and plurals, and `values(v)` normalizes a multi-valued
|
|
210
|
+
field — one real shard holds the same field as an array, as `"['a', 'b']"` and
|
|
211
|
+
as `"a,b"`, and handing an agent two shapes for one field is how it filters on
|
|
212
|
+
the wrong one.
|
|
213
|
+
|
|
214
|
+
One ordering matters and it is silent when you get it wrong: `rowText` runs per
|
|
215
|
+
row *during parsing*, so anything it depends on must exist before the first
|
|
216
|
+
shard is read. Derive roles after `loadShard` resolves and every row is already
|
|
217
|
+
parsed with the default — search then matches nothing and nothing errors. That
|
|
218
|
+
is what `configure({ onManifests })` is for; the format puts every manifest on
|
|
219
|
+
the wire before any shard.
|
|
220
|
+
|
|
221
|
+
## What an app has to pin
|
|
222
|
+
|
|
223
|
+
Almost nothing — but what there is, is permanent, because it goes on-chain.
|
|
224
|
+
|
|
225
|
+
| pin | where | why it can never change |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| **namespace** | `resourceIdFor(ns, …)` | folded into every resourceId ever minted. A different `ns` derives different ids, and bytes already stored become unreachable from the resource someone paid for. |
|
|
228
|
+
| **`file`** | `buildTreeGraph(nodes, { file })` | the fs node type, vertex tag and payload `kind`. Every vertex already committed says it. Default `"file"` — a new app takes it. |
|
|
229
|
+
| **`passages`** | same | the timestamped-text sidecar (cues, chapters, a transcript): its tag, edge rel, back-pointer field and id suffix. `null` if an app's files have no timeline. |
|
|
230
|
+
| **`pointerFields`** | same | which keys of a node's `published` blob reach the payload. `null` passes the blob through whole, which is right for a new app; pin a list once something is committed, so a field added upstream can't quietly start appearing. |
|
|
231
|
+
|
|
232
|
+
`sond3r/server/graph.js` and `sond3r/src/pay/envelope.js` are what a pin looks
|
|
233
|
+
like: a short file that holds the constants still and re-exports the rest.
|
|
234
|
+
|
|
235
|
+
## What an app has to wire
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
import { configure } from "@fangorn/westmarch/shard";
|
|
239
|
+
|
|
240
|
+
configure({
|
|
241
|
+
// How to learn WHICH view to read. A callback, not a constant: a view id
|
|
242
|
+
// embeds its requester, so repointing an app must not need a rebuild.
|
|
243
|
+
resolveView: () => "https://registry.example/q/qb_1",
|
|
244
|
+
// The free text a lexical query matches, per row. Default: name/path/desc.
|
|
245
|
+
rowText: (f) => [f.name, f.path, f.desc].filter(Boolean).join(" "),
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Then `loadShard()` gives you every row, streamed, tombstones resolved, cached per
|
|
250
|
+
view; `watchShard(fn)` drops that cache when the view gains shards;
|
|
251
|
+
`suggestDomains(q)` ranks domains you have *not* downloaded, off the coverage
|
|
252
|
+
centroids in `catalog.json` — which is how you choose what to pull before the
|
|
253
|
+
bytes move.
|
|
254
|
+
|
|
255
|
+
## What it deliberately does not do
|
|
256
|
+
|
|
257
|
+
No row schema, no plugin system, no adapter interface. A row is
|
|
258
|
+
`{ id, owner, ...fields, text, vector, norm }` and what those fields *mean* is
|
|
259
|
+
the app's: what counts as a file, how hits group into results, what a price is.
|
|
260
|
+
`sond3r/src/catalog/search.js` is that layer for one app — series collapsing,
|
|
261
|
+
subtitle seeks, an x402f pointer projection — and none of it belongs here.
|
|
262
|
+
|
|
263
|
+
No UI. The React publisher and storefront still live in sond3r. A component API
|
|
264
|
+
designed against one consumer is a guess; the second app is what earns it.
|
|
265
|
+
|
|
266
|
+
## Test
|
|
267
|
+
|
|
268
|
+
```sh
|
|
269
|
+
pnpm test # every module's own self-check, no network, no browser
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`publish/envelope.js` pins fixed vectors for sond3r's live ids. If that check
|
|
273
|
+
fails, the extraction broke money.
|
|
274
|
+
|
|
275
|
+
`example/` is a second consumer — five WebMCP verbs and a telemetry page — run
|
|
276
|
+
against two unrelated bundles to prove the seam holds. See its README.
|
|
277
|
+
|
|
278
|
+
`example-publisher/` is the other half: the smallest directory that publishes a
|
|
279
|
+
priced, encrypted file to a Fangorn app. Its only dependency is this package —
|
|
280
|
+
publishing to an app does not require a clone of that app. See its README.
|
package/consume/apps.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The index of apps — read off the chain.
|
|
2
|
+
//
|
|
3
|
+
// An app owner binds its agent card with `setAppAgentUri`, and the AppRegistry
|
|
4
|
+
// emits `AppAgentChanged(appId, uri)`. The log IS the directory: no registry
|
|
5
|
+
// namespace to bake, no relay to ask, nobody's server in the loop. Scan the
|
|
6
|
+
// logs, keep each app's latest uri, and hand every uri to `discoverApp`, which
|
|
7
|
+
// fetches the card and checks it names that app and is still the one bound on
|
|
8
|
+
// chain. The card then says where the data is (`views`) and where it starts
|
|
9
|
+
// (`fromBlock`).
|
|
10
|
+
//
|
|
11
|
+
// `fangorn` is an SDK client, passed in rather than imported, so this module
|
|
12
|
+
// stays usable where the SDK is not installed, and testable without a chain.
|
|
13
|
+
// Reads only: a reader can create one with a throwaway key.
|
|
14
|
+
//
|
|
15
|
+
// ponytail: one flat list, no ranking. Every card is one fetch, all in
|
|
16
|
+
// parallel. Past a few hundred apps, rank by the cards' own coverage instead of
|
|
17
|
+
// fetching them all.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A pointer someone else wrote, and following it is the one place a reader
|
|
21
|
+
* navigates to a URL it did not configure. So: http(s) only. `javascript:` and
|
|
22
|
+
* `data:` never reach a fetch or an EventSource from here.
|
|
23
|
+
*/
|
|
24
|
+
function safeView(url) {
|
|
25
|
+
try {
|
|
26
|
+
const u = new URL(String(url));
|
|
27
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
28
|
+
return u.toString().replace(/\/+$/, "").replace(/\/(stream|cdn)$/, "");
|
|
29
|
+
} catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The SDK's FANGORN_APP_EXTENSION. Copied so this file needs no SDK;
|
|
33
|
+
* publish/agent-card.js's self-check fails if the two drift. */
|
|
34
|
+
export const APP_EXTENSION = "https://fangorn.network/a2a/app/v1";
|
|
35
|
+
|
|
36
|
+
/** A verified card → an app entry. `views` is westmarch's param; the SDK does not read it. */
|
|
37
|
+
export function toApp({ card, appId, fromBlock, namespaces }, cardUrl) {
|
|
38
|
+
const ext = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION);
|
|
39
|
+
const views = (Array.isArray(ext?.params?.views) ? ext.params.views : []).map(safeView).filter(Boolean);
|
|
40
|
+
return {
|
|
41
|
+
appId, card: cardUrl, fromBlock, namespaces, views,
|
|
42
|
+
name: card.name ?? appId,
|
|
43
|
+
desc: card.description ?? "",
|
|
44
|
+
url: safeView(card.url),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every app with a bound, verifiable card. The ones that fail are reported
|
|
50
|
+
* with the reason, not dropped: a card that is down and a card that lies look
|
|
51
|
+
* the same from outside, and an agent should see both.
|
|
52
|
+
*
|
|
53
|
+
* An app that cleared its uri (bound to "") is skipped. It is not an error.
|
|
54
|
+
*/
|
|
55
|
+
export async function listApps(fangorn, { fromBlock, toBlock } = {}) {
|
|
56
|
+
if (fromBlock == null) throw new Error("listApps: pass fromBlock, a block at or before the AppRegistry's deployment — from 0 is one RPC call per 1000 blocks of chain history");
|
|
57
|
+
const logs = await fangorn.getAppRegistry().getAppAgentLogs({ fromBlock: BigInt(fromBlock), toBlock });
|
|
58
|
+
// Oldest first, so the last write wins. discoverApp re-reads the binding,
|
|
59
|
+
// so a stale uri here is caught there, not trusted.
|
|
60
|
+
const latest = new Map(logs.map((l) => [l.appId, l.agentUri]));
|
|
61
|
+
const apps = [], rejected = [];
|
|
62
|
+
await Promise.all([...latest].map(async ([appId, uri]) => {
|
|
63
|
+
if (!uri) return;
|
|
64
|
+
try { apps.push(toApp(await fangorn.discoverApp(uri), uri)); }
|
|
65
|
+
catch (e) { rejected.push({ appId, card: uri, why: e?.message ?? String(e) }); }
|
|
66
|
+
}));
|
|
67
|
+
apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
68
|
+
return { apps, rejected };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── self-check: `node consume/apps.js` ──────────────────────────────────────
|
|
72
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
73
|
+
const j = (x) => JSON.stringify(x, (_, v) => (typeof v === "bigint" ? `${v}n` : v));
|
|
74
|
+
const eq = (a, b, m) => { if (j(a) !== j(b)) throw new Error(`${m}: ${j(a)} != ${j(b)}`); };
|
|
75
|
+
const A = "0x" + "a".repeat(64), B = "0x" + "b".repeat(64), C = "0x" + "c".repeat(64);
|
|
76
|
+
const card = (appId, name, views) => ({ name, description: `${name}!`, url: "https://x.test/", capabilities: { extensions: [
|
|
77
|
+
{ uri: "https://other/ext", params: { appId, views: ["https://evil.test/"] } },
|
|
78
|
+
{ uri: "https://fangorn.network/a2a/app/v1", params: { appId, views } },
|
|
79
|
+
] } });
|
|
80
|
+
const cards = {
|
|
81
|
+
"https://a.test/card": card(A, "Beta", ["https://a.test/q/v1/stream", "javascript:alert(1)"]),
|
|
82
|
+
"https://a.test/old": card(A, "Old", []),
|
|
83
|
+
"https://b.test/card": card(B, "Alpha", []),
|
|
84
|
+
};
|
|
85
|
+
let askedFrom;
|
|
86
|
+
const fangorn = {
|
|
87
|
+
getAppRegistry: () => ({ getAppAgentLogs: ({ fromBlock }) => (askedFrom = fromBlock, Promise.resolve([
|
|
88
|
+
{ appId: A, agentUri: "https://a.test/old" },
|
|
89
|
+
{ appId: B, agentUri: "https://b.test/card" },
|
|
90
|
+
{ appId: A, agentUri: "https://a.test/card" }, // A rebound: last wins
|
|
91
|
+
{ appId: C, agentUri: "https://c.test/card" },
|
|
92
|
+
{ appId: C, agentUri: "" }, // C unbound
|
|
93
|
+
{ appId: "0x" + "d".repeat(64), agentUri: "https://liar.test/card" },
|
|
94
|
+
])) }),
|
|
95
|
+
discoverApp: async (uri) => {
|
|
96
|
+
if (uri === "https://liar.test/card") throw new Error("not bound to app");
|
|
97
|
+
const c = cards[uri];
|
|
98
|
+
return { card: c, appId: c.capabilities.extensions[1].params.appId, fromBlock: 7n, namespaces: ["n"] };
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const { apps, rejected } = await listApps(fangorn, { fromBlock: 42 });
|
|
102
|
+
eq(askedFrom, 42n, "fromBlock forwarded as bigint");
|
|
103
|
+
eq(apps.map((a) => a.name), ["Alpha", "Beta"], "one entry per app, latest card, sorted, unbound skipped");
|
|
104
|
+
const beta = apps[1];
|
|
105
|
+
eq(beta.views, ["https://a.test/q/v1"], "views from the fangorn extension only, trimmed, non-http dropped");
|
|
106
|
+
eq([beta.appId, beta.card, beta.url, beta.fromBlock, beta.desc], [A, "https://a.test/card", "https://x.test", 7n, "Beta!"], "entry fields");
|
|
107
|
+
eq(rejected.map((r) => r.why), ["not bound to app"], "a card that fails verification is reported");
|
|
108
|
+
await listApps(fangorn).then(() => { throw new Error("missing fromBlock must throw"); }, () => {});
|
|
109
|
+
|
|
110
|
+
console.log("apps.js self-check ok — latest card per app, unbound skipped, failures reported, views gated to http(s)");
|
|
111
|
+
}
|