@estiva-app/interop 0.1.0
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/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/projection.d.ts +524 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +1334 -0
- package/dist/projection.js.map +1 -0
- package/package.json +55 -0
- package/src/index.ts +46 -0
- package/src/projection.ts +1833 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @estiva-app/interop
|
|
2
|
+
|
|
3
|
+
Every entry answers the **manifest** question explicitly, including when the
|
|
4
|
+
answer is nothing (ADR 0002 §4b). A change to what a manifest may declare, or to
|
|
5
|
+
how a declaration is read, is a MAJOR — in `0.x`, a MINOR — even when no
|
|
6
|
+
TypeScript signature moved. A consumer upgrading must be able to tell whether
|
|
7
|
+
manifests already published still mean what they meant.
|
|
8
|
+
|
|
9
|
+
## 0.1.0 — unreleased
|
|
10
|
+
|
|
11
|
+
First publish. Extracted from `peek-app/interop/`, which was extracted from
|
|
12
|
+
`peek-app/convex/nostr/projection.ts` (PRO-1) — a path that said Convex about a
|
|
13
|
+
file whose own header said it knew nothing about the app it rendered.
|
|
14
|
+
|
|
15
|
+
**Manifest behaviour: unchanged from what is live.** Peek and Ship have been
|
|
16
|
+
publishing and reading these manifests on production throughout; this packages
|
|
17
|
+
the reader without altering what it reads.
|
|
18
|
+
|
|
19
|
+
What it resolves:
|
|
20
|
+
|
|
21
|
+
- **`resolveManifest`** — find the app that handles a kind, preferring the
|
|
22
|
+
object author's own `kind:31989` recommendation over a guess.
|
|
23
|
+
- **`resolveForeignObject`** — an `naddr` or `kind:pubkey:d` to slots, meta,
|
|
24
|
+
actions, comments, children and people. Reports `unreachable` when the
|
|
25
|
+
manifest resolved and the object did not, because the relay answers
|
|
26
|
+
"forbidden" and "empty" identically.
|
|
27
|
+
- **`resolveFolderProject`** — the container a Folder holds, and its children.
|
|
28
|
+
- **`buildActionEvent`** — the unsigned event a declared action emits.
|
|
29
|
+
- **`pickWidget` / `widgetChainProblem` / `CLOSED_WIDGETS`** — the fallback
|
|
30
|
+
chain, from both sides. A consumer walks it; a producer is stopped from
|
|
31
|
+
publishing one that ends nowhere.
|
|
32
|
+
|
|
33
|
+
**Not here, and not by omission:** no fold, no rendering, no relay client. See
|
|
34
|
+
the README's last section for why each is excluded.
|
|
35
|
+
|
|
36
|
+
### Why it is 0.1.0 and not 1.0.0
|
|
37
|
+
|
|
38
|
+
Two consumers have exercised it — Peek since it was written, Ship since PRO-7 —
|
|
39
|
+
and the second one changed the API on contact: `ForeignObject.widget` was typed
|
|
40
|
+
`string` while the wire carried a chain, which no amount of use by the first
|
|
41
|
+
consumer had revealed. **A third consumer will do it again.** The version says
|
|
42
|
+
so.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Estiva
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# @estiva-app/interop
|
|
2
|
+
|
|
3
|
+
Render and act on **another app's objects**, from a manifest that app published.
|
|
4
|
+
Your app learns nothing about theirs.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install @estiva-app/interop @estiva-app/protocol
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
No registry auth, no `.npmrc`, no token — [ADR 0002 §3](https://github.com/estiva-app/estiva-docs/blob/main/decisions/0002-foundation-packages.md).
|
|
11
|
+
|
|
12
|
+
## The one rule everything follows
|
|
13
|
+
|
|
14
|
+
**The owner defines the projection. The consumer decides how it looks.**
|
|
15
|
+
|
|
16
|
+
An app that owns objects publishes a NIP-89 `kind:31990` manifest saying which
|
|
17
|
+
of its fields matter, what its statuses mean, and what another app may *do* to
|
|
18
|
+
them. It never says how to draw anything — an owner who could specify layout
|
|
19
|
+
would be designing your product, which is the same objection that rules out
|
|
20
|
+
iframes.
|
|
21
|
+
|
|
22
|
+
So this package returns values with enough shape to render, and no components.
|
|
23
|
+
Nothing in it knows what any particular app is.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1. Render an object from an app you know nothing about
|
|
28
|
+
|
|
29
|
+
You are holding a reference somebody pasted — `nostr:naddr1…`, or a bare
|
|
30
|
+
`kind:pubkey:d`. You do not know which app owns it.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { resolveForeignObject } from '@estiva-app/interop'
|
|
34
|
+
import { Relay } from '@estiva-app/protocol'
|
|
35
|
+
|
|
36
|
+
const relay = new Relay('https://your.relay', signer)
|
|
37
|
+
const query = (filters) => relay.query(filters)
|
|
38
|
+
|
|
39
|
+
const object = await resolveForeignObject(reference, query)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`query` is the only thing this package touches the outside world with, and you
|
|
43
|
+
supply it. There is no client inside, no global, and no configuration — which is
|
|
44
|
+
also why the whole test suite runs with an array standing in for the relay.
|
|
45
|
+
|
|
46
|
+
What comes back:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
{
|
|
50
|
+
ref: '30851:abc…:9c69f247-…', // stable handle, whatever the object is
|
|
51
|
+
kind: 30851,
|
|
52
|
+
appName: 'Estiva Ship',
|
|
53
|
+
widget: 'row', // a hint. See §3
|
|
54
|
+
slots: {
|
|
55
|
+
title: { value: 'Billing entry' },
|
|
56
|
+
status: { value: 'In Progress', colour: 'blue' },
|
|
57
|
+
subtitle: { value: 'Settings entry point.' },
|
|
58
|
+
},
|
|
59
|
+
meta: [{ label: 'Assignee', value: 'abc…', isPubkey: true }],
|
|
60
|
+
children: [ /* … more of the same, if the owner declared a list */ ],
|
|
61
|
+
actions: [ /* … see §2 */ ],
|
|
62
|
+
openUrl: 'https://ship.estiva.app/#/o/naddr1…',
|
|
63
|
+
people: { 'abc…': { displayName: 'Ana', picture: '…' } },
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Render the slots you implement and ignore the ones you do not.** That is not
|
|
68
|
+
politeness, it is required: the slot set is closed but it *grows*, and producers
|
|
69
|
+
upgrade before consumers do. `title` is always present, so an object you only
|
|
70
|
+
half understand still renders as a named, resolvable thing.
|
|
71
|
+
|
|
72
|
+
### The states you must draw, and the one that catches everyone
|
|
73
|
+
|
|
74
|
+
A foreign object has more failure states than anything else on your screen,
|
|
75
|
+
because you control none of its lifecycle.
|
|
76
|
+
|
|
77
|
+
| state | how you know |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| resolved | you got an object |
|
|
80
|
+
| **you may not see it** | `object.unreachable === true` |
|
|
81
|
+
| nothing claims this kind | `null` |
|
|
82
|
+
| declared a list, and it is empty | `children` is `[]` |
|
|
83
|
+
| declared no list | `children` is `undefined` |
|
|
84
|
+
|
|
85
|
+
**"You may not see it" and "it is empty" must never look the same.** A gated
|
|
86
|
+
read returns *nothing at all* — the same nothing as a thing with no content —
|
|
87
|
+
so the relay cannot distinguish them and neither can you unless you use
|
|
88
|
+
`unreachable`. Draw one state for both and you have built a screen that quietly
|
|
89
|
+
lies.
|
|
90
|
+
|
|
91
|
+
## 2. Let a reader act on it, without an API
|
|
92
|
+
|
|
93
|
+
An action is **an event to publish**, never an endpoint to call. There is no
|
|
94
|
+
server in the loop, and the owning app can be offline.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { buildActionEvent } from '@estiva-app/interop'
|
|
98
|
+
|
|
99
|
+
const unsigned = buildActionEvent({ object, actionId: 'set-issue-status', value: 'done' })
|
|
100
|
+
const signed = await signer.sign(unsigned)
|
|
101
|
+
await relay.publish(signed)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`object.actions` is already resolved against the owner's vocabularies, so a
|
|
105
|
+
`select` arrives with its options and the value it currently holds.
|
|
106
|
+
|
|
107
|
+
Two consequences worth knowing before you ship it. **Validation is an honour
|
|
108
|
+
system** — nothing stops you publishing a status outside the declared
|
|
109
|
+
vocabulary, and a consumer that skips the check is the one putting junk in a
|
|
110
|
+
shared record. And **an object with no `naddr` offers no actions**: a change
|
|
111
|
+
names its target with an `a` tag, and a regular event cannot be named that way.
|
|
112
|
+
That is the model being honest, not a gap.
|
|
113
|
+
|
|
114
|
+
## 3. Widgets: draw what you know, degrade honestly
|
|
115
|
+
|
|
116
|
+
`widget` is a *layout hint*, and it may be a single type or an ordered chain:
|
|
117
|
+
|
|
118
|
+
```jsonc
|
|
119
|
+
"widget": ["message", "card"] // a message if you know it, else a card
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A chain always ends in one of `card`, `row`, `table`, `stat`, so **there is
|
|
123
|
+
always something you can draw.**
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { pickWidget, CLOSED_WIDGETS } from '@estiva-app/interop'
|
|
127
|
+
|
|
128
|
+
const layout = pickWidget(object.widget, ['message', ...CLOSED_WIDGETS], 'card')
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Never render nothing. An object that is present but blank is indistinguishable
|
|
132
|
+
from one the reader is not allowed to see, and reports *"that app is broken"*
|
|
133
|
+
about an app that is behaving correctly.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 4. Make *your* objects renderable by other apps
|
|
138
|
+
|
|
139
|
+
Publish one `kind:31990`. Here is a hiring tool declaring a Candidate — an app
|
|
140
|
+
this suite knows nothing about, which is the point.
|
|
141
|
+
|
|
142
|
+
```jsonc
|
|
143
|
+
{
|
|
144
|
+
"name": "Hiring",
|
|
145
|
+
"records": {
|
|
146
|
+
"changeKind": 1851, "targetTag": "a", "fieldTag": "field", "valueTag": "value",
|
|
147
|
+
"order": ["ts", "created_at", "id"], "rule": "last-write-wins-per-field"
|
|
148
|
+
},
|
|
149
|
+
"projections": {
|
|
150
|
+
"31800": {
|
|
151
|
+
"widget": ["candidate", "card"],
|
|
152
|
+
"slots": {
|
|
153
|
+
"title": { "tag": "name" },
|
|
154
|
+
"subtitle": { "tag": "headline", "truncate": 120 },
|
|
155
|
+
"status": { "fold": "stage", "map": "stages", "default": "applied" },
|
|
156
|
+
"meta": [{ "label": "Recruiter", "fold": "owner", "as": "pubkey" }],
|
|
157
|
+
"list": { "children": { "kind": 31801, "via": "a", "limit": 50 } }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"vocabularies": {
|
|
162
|
+
"stages": [
|
|
163
|
+
{ "value": "applied", "label": "Applied", "colour": "neutral", "stage": "open" },
|
|
164
|
+
{ "value": "screening", "label": "Screening", "colour": "blue", "stage": "started" },
|
|
165
|
+
{ "value": "hired", "label": "Hired", "colour": "green", "stage": "done" },
|
|
166
|
+
{ "value": "passed", "label": "Passed", "colour": "muted", "stage": "dropped" }
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
"actions": [
|
|
170
|
+
{
|
|
171
|
+
"id": "set-stage", "label": "Change stage", "appliesTo": "31800",
|
|
172
|
+
"emits": { "kind": 1851, "field": "stage" },
|
|
173
|
+
"input": { "type": "string", "enum": "stages" },
|
|
174
|
+
"description": "Move a candidate to a different hiring stage",
|
|
175
|
+
"effect": "writes"
|
|
176
|
+
}
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A chat app now renders your candidate — with your stages, your colours, your
|
|
182
|
+
recruiter — in **its** design language, and lets someone move a stage without
|
|
183
|
+
leaving the conversation. It knows nothing about hiring.
|
|
184
|
+
|
|
185
|
+
Five things that will bite, each of them something we got wrong first:
|
|
186
|
+
|
|
187
|
+
- **`title` is required.** It is what makes ignoring an unknown slot safe.
|
|
188
|
+
- **A mutable field must be a `fold`, never a `tag`.** A status that can be set
|
|
189
|
+
by someone who is not the author does not live on the root event, so a `tag`
|
|
190
|
+
renders empty for ever.
|
|
191
|
+
- **`stage` says what a status *means*.** Without it a consumer reporting
|
|
192
|
+
progress has to guess from your label, and the only way to guess is a list of
|
|
193
|
+
English words — which fails for the next app that spells things differently.
|
|
194
|
+
`dropped` is the one nothing can infer: neither outstanding nor progress.
|
|
195
|
+
- **Never `truncate` a field that carries structure.** `truncate` is a
|
|
196
|
+
plain-text operation. A `subtitle` promises plain text; a `body` carries
|
|
197
|
+
structure. Cut markdown at 120 characters and you have published 120
|
|
198
|
+
characters of markup.
|
|
199
|
+
- **A widget chain must terminate in a closed type.** `["candidate"]` is not
|
|
200
|
+
publishable. `widgetChainProblem()` is exported so you can check before you
|
|
201
|
+
sign — a manifest is read by apps that cannot ask what you meant.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## What is not in here, deliberately
|
|
206
|
+
|
|
207
|
+
**No fold.** Manifest semantics are normative; an app's interpretation of its
|
|
208
|
+
own records is not. Two apps folding the same events are *supposed* to be able
|
|
209
|
+
to differ ([SPEC §10](https://github.com/estiva-app/estiva-docs/blob/main/protocol/SPEC.md)).
|
|
210
|
+
|
|
211
|
+
**No rendering.** Not a React dependency, not a component. A package that
|
|
212
|
+
shipped a widget would be specifying the UI of every app that installed it.
|
|
213
|
+
|
|
214
|
+
**No relay client.** `@estiva-app/protocol` has two; this takes a function.
|
|
215
|
+
|
|
216
|
+
## The specification, which outranks this package
|
|
217
|
+
|
|
218
|
+
[SPEC §7](https://github.com/estiva-app/estiva-docs/blob/main/protocol/SPEC.md)
|
|
219
|
+
is normative and **must stay sufficient to implement all of this without
|
|
220
|
+
installing anything**. If this package ever becomes the only place that knows
|
|
221
|
+
how projection works, an interop standard has quietly been traded for a
|
|
222
|
+
monoculture — and then a defect in it is a defect in every app at once,
|
|
223
|
+
invisible from all of them.
|
|
224
|
+
|
|
225
|
+
**The package is a reference implementation. The specification is the standard.**
|
|
226
|
+
In that order.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@estiva-app/interop` — render and act on another app's objects.
|
|
3
|
+
*
|
|
4
|
+
* An app publishes a NIP-89 `kind:31990` manifest saying how its objects should
|
|
5
|
+
* be projected: which slots matter, what its statuses mean, what another app
|
|
6
|
+
* may do to them. This resolves that manifest and hands a consumer something it
|
|
7
|
+
* can draw, **without the consumer knowing anything about the owning app**.
|
|
8
|
+
*
|
|
9
|
+
* The governing rule, and the reason the package is small:
|
|
10
|
+
* **the owner defines the projection, the consumer decides how it looks.**
|
|
11
|
+
* Nothing here returns layout, and nothing here should ever grow a rule about a
|
|
12
|
+
* particular app.
|
|
13
|
+
*
|
|
14
|
+
* ## What is not here, deliberately
|
|
15
|
+
*
|
|
16
|
+
* **No fold.** Manifest semantics are normative; an app's interpretation of its
|
|
17
|
+
* own records is not. `records` tells a consumer how to fold, and folding is the
|
|
18
|
+
* consumer's business — see the Estiva SPEC §10 on why two apps are *supposed*
|
|
19
|
+
* to be able to differ there.
|
|
20
|
+
*
|
|
21
|
+
* **No rendering.** Not a React dependency, not a component. Slots come back as
|
|
22
|
+
* values with enough shape to draw, and every consumer draws them in its own
|
|
23
|
+
* design language. A package that shipped a widget would be specifying the UI
|
|
24
|
+
* of every app that installed it, which is the objection that rules out
|
|
25
|
+
* iframes.
|
|
26
|
+
*/
|
|
27
|
+
export { resolveManifest, resolveForeignObject, resolveFolderProject, commentKindsOf, buildActionEvent, pickWidget, widgetChainProblem, CLOSED_WIDGETS, peopleViaRelay, type QueryFn, type PeopleFn, type People, type ForeignObject, type FolderProject, type ResolvedSlot, type ResolvedAction, type ManifestAction, type UnsignedActionEvent, } from './projection.js';
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,oBAAoB,EACpB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,KAAK,OAAO,EACZ,KAAK,QAAQ,EACb,KAAK,MAAM,EACX,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,iBAAiB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@estiva-app/interop` — render and act on another app's objects.
|
|
3
|
+
*
|
|
4
|
+
* An app publishes a NIP-89 `kind:31990` manifest saying how its objects should
|
|
5
|
+
* be projected: which slots matter, what its statuses mean, what another app
|
|
6
|
+
* may do to them. This resolves that manifest and hands a consumer something it
|
|
7
|
+
* can draw, **without the consumer knowing anything about the owning app**.
|
|
8
|
+
*
|
|
9
|
+
* The governing rule, and the reason the package is small:
|
|
10
|
+
* **the owner defines the projection, the consumer decides how it looks.**
|
|
11
|
+
* Nothing here returns layout, and nothing here should ever grow a rule about a
|
|
12
|
+
* particular app.
|
|
13
|
+
*
|
|
14
|
+
* ## What is not here, deliberately
|
|
15
|
+
*
|
|
16
|
+
* **No fold.** Manifest semantics are normative; an app's interpretation of its
|
|
17
|
+
* own records is not. `records` tells a consumer how to fold, and folding is the
|
|
18
|
+
* consumer's business — see the Estiva SPEC §10 on why two apps are *supposed*
|
|
19
|
+
* to be able to differ there.
|
|
20
|
+
*
|
|
21
|
+
* **No rendering.** Not a React dependency, not a component. Slots come back as
|
|
22
|
+
* values with enough shape to draw, and every consumer draws them in its own
|
|
23
|
+
* design language. A package that shipped a widget would be specifying the UI
|
|
24
|
+
* of every app that installed it, which is the objection that rules out
|
|
25
|
+
* iframes.
|
|
26
|
+
*/
|
|
27
|
+
export { resolveManifest, resolveForeignObject, resolveFolderProject, commentKindsOf, buildActionEvent, pickWidget, widgetChainProblem, CLOSED_WIDGETS, peopleViaRelay, } from './projection.js';
|
|
28
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,oBAAoB,EACpB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,cAAc,GAUf,MAAM,iBAAiB,CAAA"}
|