@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 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.
@@ -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"}