@estiva-app/interop 0.9.0 → 0.10.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 CHANGED
@@ -6,6 +6,82 @@ how a declaration is read, is a MAJOR — in `0.x`, a MINOR — even when no
6
6
  TypeScript signature moved. A consumer upgrading must be able to tell whether
7
7
  manifests already published still mean what they meant.
8
8
 
9
+ ## 0.10.0 — 2026-09-03
10
+
11
+ **RFC 0.5 §7's URL grammar, which was accepted with amendments today.** No
12
+ existing manifest changes meaning; this adds a shape an app MAY declare and the
13
+ matching a consumer needs to read it.
14
+
15
+ ### Added
16
+
17
+ - **`slugify`, `objectRef`, `identifierFromRef`** — `<type>/<slug>-<d>`, built
18
+ and read. **Moved from Peek** (`src/lib/objectUrl.ts`, PEE-14) rather than
19
+ rewritten, with its cases, because an extraction that changes behaviour while
20
+ every test still passes is the thing to guard against.
21
+
22
+ Here rather than in each app because §7.1's argument is that the URL one app
23
+ puts in the address bar must be resolvable by another — SPEC §10's test for
24
+ something the protocol owns. Two implementations would disagree about what a
25
+ slug may contain, and the disagreement shows up as a link that resolves only
26
+ in the app that wrote it. Ship was about to grow the second copy (SHI-16).
27
+
28
+ - **`urlPatternsOf`, `matchObjectUrl`, `UrlPattern`, `MatchedObjectUrl`** —
29
+ §7.5's inbound half: given a pasted link and the shapes apps publish, recover
30
+ which object it names. A URL no pattern claims returns `null`, which is the
31
+ honest outcome — it is a link, and nothing should claim otherwise.
32
+
33
+ ### The amendment this implements
34
+
35
+ §7.2 said a consumer MUST ignore *everything* before the final uuid, which
36
+ contradicted the paragraph above it and, read literally, resolves
37
+ `evil.example.com/issue/<uuid>` as a Ship issue. **The host and the `<type>`
38
+ segment select the app and the kind; only the slug is decoration**, and
39
+ `matchObjectUrl` enforces that. It is the difference between a resolver and a
40
+ gadget for rendering an attacker's chosen object inside your app.
41
+
42
+ ### One thing §7.5 leaves open, decided here
43
+
44
+ A bare pattern tells a consumer *which app*, not which kind: `/issue/` means
45
+ `30851` only to the app serving it. So `urls` MAY carry the kind as a third tag
46
+ element. When it does not, a consumer falls back to the kinds that manifest
47
+ declares it handles — sound because no `d` is reused under two kinds, measured
48
+ across all 280 production records at §7's acceptance.
49
+
50
+ ## 0.9.1 — 2026-09-03
51
+
52
+ **No behaviour change. The README was wrong, and this is the release that fixes
53
+ the page people are told to start from** (PRO-9).
54
+
55
+ - **§2's example did not compile, and its copy-paste path published an error
56
+ message.** It called `buildActionEvent({ object, actionId, value })`, which is
57
+ not the signature: the function takes the manifest, the kind, the address, the
58
+ object's author, the folder, the signer's pubkey and a clock, because this
59
+ package reaches for none of them. And it returns `UnsignedActionEvent | string`
60
+ where **the string is the refusal** — the example signed and published the
61
+ result without checking, so a reader following it would have put the words
62
+ "This app does not offer …" on the relay.
63
+
64
+ - **The content model was undocumented.** `ResolvedSlot.format` shipped in 0.7.0
65
+ and a body cannot be rendered safely without it: §13 forbids reading either
66
+ content model as the other, and the page said nothing about which one you
67
+ have. Now a table of the three values, `toRenderTree`, and the rule that the
68
+ format is decided by the declaration and never by looking at the body.
69
+
70
+ - **Object-creating actions were undocumented** — `control: 'form'`, `fields`,
71
+ `createsUnder`, and passing an object plus a `newId`, all shipped in 0.6.0.
72
+ With the producer's side too, since `required` is a list of names on `input`
73
+ rather than a flag on each property, which is the sort of thing a page is for.
74
+
75
+ ### Added
76
+
77
+ - **`test/readme.test.ts` — the README, executed.** Every assertion is a claim
78
+ the page makes in prose, and it lives in `test/` so `npm run typecheck`
79
+ compiles it against the real declarations. The published example, pasted in
80
+ verbatim, fails that pass with `TS2353: 'object' does not exist in type`.
81
+
82
+ The page's examples had been run once, in a scratch project, when the package
83
+ was first published. That is a snapshot; this is a check.
84
+
9
85
  ## 0.9.0 — 2026-09-02
10
86
 
11
87
  **Behaviour unchanged, and no export moved.** `contentFormatOf`,
package/README.md CHANGED
@@ -55,6 +55,7 @@ What comes back:
55
55
  title: { value: 'Billing entry' },
56
56
  status: { value: 'In Progress', colour: 'blue' },
57
57
  subtitle: { value: 'Settings entry point.' },
58
+ body: { value: '**Stripe Checkout** for billing.', format: 'marker' },
58
59
  },
59
60
  meta: [{ label: 'Assignee', value: 'abc…', isPubkey: true }],
60
61
  children: [ /* … more of the same, if the owner declared a list */ ],
@@ -69,6 +70,36 @@ politeness, it is required: the slot set is closed but it *grows*, and producers
69
70
  upgrade before consumers do. `title` is always present, so an object you only
70
71
  half understand still renders as a named, resolvable thing.
71
72
 
73
+ ### A `body` says which content model it is in — read it, do not guess
74
+
75
+ `body` is the one slot that carries structure, and it arrives in one of two
76
+ models that must never be read as each other. **The slot tells you which**, so
77
+ you never have to look at the text:
78
+
79
+ | `slot.body.format` | what you have |
80
+ | --- | --- |
81
+ | `'marker'` | the marker dialect — `**bold**`, `# heading`, `> quote`, fenced code |
82
+ | `'blocks'` | a JSON block document |
83
+ | `'unknown'` | a model published after your app was written |
84
+
85
+ ```ts
86
+ import { toRenderTree } from '@estiva-app/protocol'
87
+
88
+ // One tree, whichever model it came in. Marks decided, blocks decided,
89
+ // nothing left to parse — so you cannot render markup by accident.
90
+ const blocks = toRenderTree(object.slots.body.value, object.slots.body.format)
91
+ ```
92
+
93
+ **Never decide the model by inspecting the body.** A description that happens to
94
+ begin with `{` is marker text if its event carries no `content-format` tag, and
95
+ there are hundreds of those already published. `'unknown'` exists so you can
96
+ decline: rendering a body you do not understand as plain text is correct, and
97
+ guessing at it is not.
98
+
99
+ A slot that is *not* a body carries no `format` at all — that is how you tell
100
+ "this is prose in the marker dialect" from "this is a title, and models do not
101
+ apply to it".
102
+
72
103
  ### The states you must draw, and the one that catches everyone
73
104
 
74
105
  A foreign object has more failure states than anything else on your screen,
@@ -94,16 +125,89 @@ An action is **an event to publish**, never an endpoint to call. There is no
94
125
  server in the loop, and the owning app can be offline.
95
126
 
96
127
  ```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)
128
+ import { buildActionEvent, resolveManifest } from '@estiva-app/interop'
129
+
130
+ const resolved = await resolveManifest(object.kind, query)
131
+
132
+ const built = buildActionEvent({
133
+ manifest: resolved.manifest,
134
+ kind: object.kind,
135
+ address: object.ref,
136
+ objectAuthor: pointer.pubkey,
137
+ folder, // the object's channel — see below
138
+ actionId: 'set-issue-status',
139
+ value: 'done',
140
+ pubkey: me, // whoever is about to sign
141
+ createdAtMs: Date.now(),
142
+ })
143
+
144
+ // A refusal is a string, and it is written for a person to read.
145
+ if (typeof built === 'string') return show(built)
146
+
147
+ await relay.publish(await signer.sign(built))
102
148
  ```
103
149
 
150
+ **Check the string.** `buildActionEvent` returns `UnsignedActionEvent | string`,
151
+ and the string is why it refused — an undeclared field, a required one left
152
+ empty, a value outside the declared vocabulary. Treat the result as an event
153
+ without checking and you will sign the refusal.
154
+
155
+ Everything it needs is passed in rather than reached for: this package opens no
156
+ socket, reads no clock and holds no identity, so the built event is a pure
157
+ function of its inputs and the whole suite runs against an array.
158
+
104
159
  `object.actions` is already resolved against the owner's vocabularies, so a
105
160
  `select` arrives with its options and the value it currently holds.
106
161
 
162
+ ### Actions that create an object, not just change one
163
+
164
+ An action with `control: 'form'` makes a whole new object rather than setting a
165
+ field on this one. It arrives with the schema already resolved:
166
+
167
+ ```ts
168
+ const action = object.actions.find((a) => a.control === 'form')
169
+
170
+ action.fields // [{ name: 'title', type: 'string', required: true }, …]
171
+ action.createsUnder // the address the new object will hang under
172
+ ```
173
+
174
+ Draw the fields, then pass an object rather than a scalar, plus an id for the
175
+ thing being made:
176
+
177
+ ```ts
178
+ const built = buildActionEvent({
179
+ /* …as above… */
180
+ actionId: action.id,
181
+ value: { title: 'Payment fails on retry' },
182
+ newId: crypto.randomUUID(),
183
+ })
184
+ ```
185
+
186
+ Declaring one, from the producer's side — `required` is a list of names, not a
187
+ flag on each property:
188
+
189
+ ```jsonc
190
+ {
191
+ "id": "add-issue", "label": "Add issue", "appliesTo": "31800",
192
+ "emits": { "kind": 31801, "setTag": "a", "toAddressOf": "self" },
193
+ "input": {
194
+ "type": "object",
195
+ "properties": { "title": { "type": "string" }, "note": { "type": "string" } },
196
+ "required": ["title"]
197
+ }
198
+ }
199
+ ```
200
+
201
+ `newId` is **required** for an addressable kind and is supplied by you rather
202
+ than generated here — an object published without one has no address at all, so
203
+ nothing could reference it, comment on it or act on it afterwards.
204
+
205
+ Two rules worth knowing before you draw the form. **A property's name is the tag
206
+ its value is written to**, which is what lets a consumer build an event for an
207
+ app it has never seen. And **nothing can target an event's `content`** — an app
208
+ whose body lives there cannot have it filled from outside, which is why an
209
+ action may declare a `description` field that ends up in a tag.
210
+
107
211
  Two consequences worth knowing before you ship it. **Validation is an honour
108
212
  system** — nothing stops you publishing a status outside the declared
109
213
  vocabulary, and a consumer that skips the check is the one putting junk in a
package/dist/index.d.ts CHANGED
@@ -25,4 +25,13 @@
25
25
  * iframes.
26
26
  */
27
27
  export { resolveManifest, resolveForeignObject, resolveForeignEvent, resolveFolderProject, commentKindsOf, buildActionEvent, pickWidget, widgetChainProblem, CLOSED_WIDGETS, contentFormatOf, BODY_SLOT, CONTENT_FORMAT_TAG, BLOCK_DOCUMENT_FORMAT, type ContentFormat, peopleViaRelay, createProjectionCache, createPeopleCache, MANIFEST_TTL_MS, PROFILE_HIT_TTL_MS, PROFILE_MISS_TTL_MS, type PeopleCache, type PeopleCacheOptions, type ProjectionCache, type ResolvedManifest, type QueryFn, type PeopleFn, type People, type ForeignObject, type FolderProject, type ResolvedSlot, type ResolvedAction, type ActionFormField, type ManifestAction, type ActionEffect, ACTION_EFFECTS, type UnsignedActionEvent, } from './projection.js';
28
+ /**
29
+ * RFC 0.5 §7's URL grammar — how an object is named in an address bar, and how
30
+ * a pasted link is matched back to one.
31
+ *
32
+ * Separate from the projection surface above because it answers a different
33
+ * question: those resolve an object you already hold, these turn a string
34
+ * somebody pasted into one you can hold.
35
+ */
36
+ export { slugify, objectRef, identifierFromRef, urlPatternsOf, matchObjectUrl, type UrlPattern, type MatchedObjectUrl, } from './objectUrl.js';
28
37
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACnB,oBAAoB,EACpB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,SAAS,EACT,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,aAAa,EAClB,cAAc,EACd,qBAAqB,EACrB,iBAAiB,EACjB,eAAe,EACf,kBAAkB,EAClB,mBAAmB,EACnB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,OAAO,EACZ,KAAK,QAAQ,EACb,KAAK,MAAM,EACX,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACnB,oBAAoB,EACpB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,SAAS,EACT,kBAAkB,EAClB,qBAAqB,EACrB,KAAK,aAAa,EAClB,cAAc,EACd,qBAAqB,EACrB,iBAAiB,EACjB,eAAe,EACf,kBAAkB,EAClB,mBAAmB,EACnB,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,OAAO,EACZ,KAAK,QAAQ,EACb,KAAK,MAAM,EACX,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,iBAAiB,CAAA;AAExB;;;;;;;GAOG;AACH,OAAO,EACL,OAAO,EACP,SAAS,EACT,iBAAiB,EACjB,aAAa,EACb,cAAc,EACd,KAAK,UAAU,EACf,KAAK,gBAAgB,GACtB,MAAM,gBAAgB,CAAA"}
package/dist/index.js CHANGED
@@ -25,4 +25,13 @@
25
25
  * iframes.
26
26
  */
27
27
  export { resolveManifest, resolveForeignObject, resolveForeignEvent, resolveFolderProject, commentKindsOf, buildActionEvent, pickWidget, widgetChainProblem, CLOSED_WIDGETS, contentFormatOf, BODY_SLOT, CONTENT_FORMAT_TAG, BLOCK_DOCUMENT_FORMAT, peopleViaRelay, createProjectionCache, createPeopleCache, MANIFEST_TTL_MS, PROFILE_HIT_TTL_MS, PROFILE_MISS_TTL_MS, ACTION_EFFECTS, } from './projection.js';
28
+ /**
29
+ * RFC 0.5 §7's URL grammar — how an object is named in an address bar, and how
30
+ * a pasted link is matched back to one.
31
+ *
32
+ * Separate from the projection surface above because it answers a different
33
+ * question: those resolve an object you already hold, these turn a string
34
+ * somebody pasted into one you can hold.
35
+ */
36
+ export { slugify, objectRef, identifierFromRef, urlPatternsOf, matchObjectUrl, } from './objectUrl.js';
28
37
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACnB,oBAAoB,EACpB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,SAAS,EACT,kBAAkB,EAClB,qBAAqB,EAErB,cAAc,EACd,qBAAqB,EACrB,iBAAiB,EACjB,eAAe,EACf,kBAAkB,EAClB,mBAAmB,EAenB,cAAc,GAEf,MAAM,iBAAiB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACnB,oBAAoB,EACpB,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,eAAe,EACf,SAAS,EACT,kBAAkB,EAClB,qBAAqB,EAErB,cAAc,EACd,qBAAqB,EACrB,iBAAiB,EACjB,eAAe,EACf,kBAAkB,EAClB,mBAAmB,EAenB,cAAc,GAEf,MAAM,iBAAiB,CAAA;AAExB;;;;;;;GAOG;AACH,OAAO,EACL,OAAO,EACP,SAAS,EACT,iBAAiB,EACjB,aAAa,EACb,cAAc,GAGf,MAAM,gBAAgB,CAAA"}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Object URLs — RFC 0.5 §7, accepted 2026-09-03.
3
+ *
4
+ * https://<app>.estiva.app/<type>s a directory of that kind
5
+ * https://<app>.estiva.app/<type>/<slug>-<d> one object
6
+ *
7
+ * **Here rather than in each app** because it is wire-visible: §7.1's whole
8
+ * argument is that the URL one app puts in the address bar has to be resolvable
9
+ * by another, which is SPEC §10's test for something the protocol owns. Two
10
+ * implementations would disagree about what a slug may contain, and the
11
+ * disagreement would show up as a link that resolves in the app that wrote it
12
+ * and nowhere else.
13
+ *
14
+ * Peek's `src/lib/objectUrl.ts` is where `slugify`, `objectRef` and
15
+ * `identifierFromRef` were written (PEE-14) and they are moved rather than
16
+ * rewritten — an extraction that changes behaviour while every test still
17
+ * passes is the thing to guard against, so Peek's own cases come with them.
18
+ *
19
+ * Two properties from §7.2 do all the work:
20
+ *
21
+ * - **The uuid is the whole identity and is never truncated.** A consumer
22
+ * resolves `{"#d": ["<uuid>"]}` — one query, no index, and no service that
23
+ * can go down and take every published link with it.
24
+ * - **The slug is decorative.** Renaming the object changes it and the link
25
+ * still resolves, because a reader ignores everything between the `<type>`
26
+ * segment and the final uuid.
27
+ *
28
+ * That second sentence is the amended one. §7.2 used to say a consumer MUST
29
+ * ignore *everything* before the final uuid, which contradicted the paragraph
30
+ * above it and, read literally, resolves `evil.example.com/issue/<uuid>` as a
31
+ * Ship issue. The host and the `<type>` segment are exactly what select the app
32
+ * and the kind; only the slug is decoration. `matchObjectUrl` implements the
33
+ * corrected rule.
34
+ */
35
+ /**
36
+ * A title reduced to URL-safe words. Decorative by contract: nothing resolves
37
+ * through it, so it is free to be lossy — accents folded, punctuation dropped,
38
+ * runs collapsed.
39
+ */
40
+ export declare function slugify(title: string): string;
41
+ /** `<slug>-<identifier>`, or the bare identifier when the title slugs to nothing. */
42
+ export declare function objectRef(identifier: string, title?: string): string;
43
+ /**
44
+ * The identity out of a ref, or `null` if it carries none.
45
+ *
46
+ * Lowercased on the way out: the relay's `d` values are lowercase uuids, and a
47
+ * link that has been through a system which upper-cased it should still resolve
48
+ * rather than silently miss.
49
+ */
50
+ export declare function identifierFromRef(ref: string): string | null;
51
+ /**
52
+ * A URL shape an app says it serves — RFC 0.5 §7.5's `urls`, read off the
53
+ * manifest event the way `web` is.
54
+ *
55
+ * `web` is outbound (given an object, build a link); this is inbound (given a
56
+ * link, recover the object). Usually the same strings, separate fields because
57
+ * they answer different questions — and because an app that changes its routes
58
+ * still has to read the links it published under the old ones.
59
+ */
60
+ export interface UrlPattern {
61
+ /** e.g. `https://ship.estiva.app/issue/<slug>-<d>` */
62
+ pattern: string;
63
+ /**
64
+ * The kind this shape names, when the app said so.
65
+ *
66
+ * §7.2 says the kind comes from the path segment — but `/issue/` means 30851
67
+ * only *to the app serving it*, and a consumer has never heard of "issue".
68
+ * So an app MAY name the kind alongside the pattern. When it does not, a
69
+ * consumer falls back to the kinds the manifest declares it handles, which is
70
+ * sound because no `d` is reused under two kinds — measured across all 280
71
+ * production records at §7's acceptance.
72
+ */
73
+ kind?: number;
74
+ }
75
+ /** The `urls` shapes a manifest event declares. */
76
+ export declare function urlPatternsOf(event: {
77
+ tags: string[][];
78
+ }): UrlPattern[];
79
+ /** What a pasted URL turned out to name. */
80
+ export interface MatchedObjectUrl {
81
+ identifier: string;
82
+ /** Present only when the matched pattern named one. */
83
+ kind?: number;
84
+ }
85
+ /**
86
+ * Match a pasted URL against one app's declared shapes.
87
+ *
88
+ * **The host and the `<type>` segment are significant; the slug is not.** That
89
+ * is §7.2 as amended, and it is the whole of the security story here: a
90
+ * consumer that matched on the trailing uuid alone would resolve any URL from
91
+ * anywhere as that app's object.
92
+ *
93
+ * Returns null for a URL no pattern claims, which is the honest outcome — §7.5
94
+ * says such a URL renders as a plain link, because it is one.
95
+ */
96
+ export declare function matchObjectUrl(url: string, patterns: UrlPattern[]): MatchedObjectUrl | null;
97
+ //# sourceMappingURL=objectUrl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"objectUrl.d.ts","sourceRoot":"","sources":["../src/objectUrl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAkBH;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS7C;AAED,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAGpE;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG5D;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU;IACzB,sDAAsD;IACtD,OAAO,EAAE,MAAM,CAAA;IACf;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,mDAAmD;AACnD,wBAAgB,aAAa,CAAC,KAAK,EAAE;IAAE,IAAI,EAAE,MAAM,EAAE,EAAE,CAAA;CAAE,GAAG,UAAU,EAAE,CAQvE;AAED,4CAA4C;AAC5C,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAA;IAClB,uDAAuD;IACvD,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,gBAAgB,GAAG,IAAI,CAuB3F"}
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Object URLs — RFC 0.5 §7, accepted 2026-09-03.
3
+ *
4
+ * https://<app>.estiva.app/<type>s a directory of that kind
5
+ * https://<app>.estiva.app/<type>/<slug>-<d> one object
6
+ *
7
+ * **Here rather than in each app** because it is wire-visible: §7.1's whole
8
+ * argument is that the URL one app puts in the address bar has to be resolvable
9
+ * by another, which is SPEC §10's test for something the protocol owns. Two
10
+ * implementations would disagree about what a slug may contain, and the
11
+ * disagreement would show up as a link that resolves in the app that wrote it
12
+ * and nowhere else.
13
+ *
14
+ * Peek's `src/lib/objectUrl.ts` is where `slugify`, `objectRef` and
15
+ * `identifierFromRef` were written (PEE-14) and they are moved rather than
16
+ * rewritten — an extraction that changes behaviour while every test still
17
+ * passes is the thing to guard against, so Peek's own cases come with them.
18
+ *
19
+ * Two properties from §7.2 do all the work:
20
+ *
21
+ * - **The uuid is the whole identity and is never truncated.** A consumer
22
+ * resolves `{"#d": ["<uuid>"]}` — one query, no index, and no service that
23
+ * can go down and take every published link with it.
24
+ * - **The slug is decorative.** Renaming the object changes it and the link
25
+ * still resolves, because a reader ignores everything between the `<type>`
26
+ * segment and the final uuid.
27
+ *
28
+ * That second sentence is the amended one. §7.2 used to say a consumer MUST
29
+ * ignore *everything* before the final uuid, which contradicted the paragraph
30
+ * above it and, read literally, resolves `evil.example.com/issue/<uuid>` as a
31
+ * Ship issue. The host and the `<type>` segment are exactly what select the app
32
+ * and the kind; only the slug is decoration. `matchObjectUrl` implements the
33
+ * corrected rule.
34
+ */
35
+ /**
36
+ * A v4 uuid at the very end of a ref.
37
+ *
38
+ * Anchored at the end rather than searched for, because §7.2's rule is
39
+ * positional: the identity is the tail, and everything before it is a human
40
+ * label that may itself contain hyphens, digits and hex.
41
+ */
42
+ const TRAILING_UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+ /**
44
+ * Slugs are cut here. Long enough to stay recognisable in a chat client's link
45
+ * preview, short enough that the uuid is not pushed off the end of a rendered
46
+ * URL — which is the only thing in the ref that carries meaning.
47
+ */
48
+ const SLUG_MAX_LENGTH = 60;
49
+ /**
50
+ * A title reduced to URL-safe words. Decorative by contract: nothing resolves
51
+ * through it, so it is free to be lossy — accents folded, punctuation dropped,
52
+ * runs collapsed.
53
+ */
54
+ export function slugify(title) {
55
+ return title
56
+ .normalize('NFKD')
57
+ .replace(/[̀-ͯ]/g, '')
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, '-')
60
+ .replace(/^-+|-+$/g, '')
61
+ .slice(0, SLUG_MAX_LENGTH)
62
+ .replace(/-+$/g, '');
63
+ }
64
+ /** `<slug>-<identifier>`, or the bare identifier when the title slugs to nothing. */
65
+ export function objectRef(identifier, title) {
66
+ const slug = title ? slugify(title) : '';
67
+ return slug ? `${slug}-${identifier}` : identifier;
68
+ }
69
+ /**
70
+ * The identity out of a ref, or `null` if it carries none.
71
+ *
72
+ * Lowercased on the way out: the relay's `d` values are lowercase uuids, and a
73
+ * link that has been through a system which upper-cased it should still resolve
74
+ * rather than silently miss.
75
+ */
76
+ export function identifierFromRef(ref) {
77
+ const match = TRAILING_UUID_RE.exec(ref);
78
+ return match ? match[0].toLowerCase() : null;
79
+ }
80
+ /** The `urls` shapes a manifest event declares. */
81
+ export function urlPatternsOf(event) {
82
+ const out = [];
83
+ for (const tag of event.tags) {
84
+ if (tag[0] !== 'urls' || !tag[1])
85
+ continue;
86
+ const kind = tag[2] ? Number(tag[2]) : undefined;
87
+ out.push({ pattern: tag[1], ...(Number.isInteger(kind) ? { kind } : {}) });
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Match a pasted URL against one app's declared shapes.
93
+ *
94
+ * **The host and the `<type>` segment are significant; the slug is not.** That
95
+ * is §7.2 as amended, and it is the whole of the security story here: a
96
+ * consumer that matched on the trailing uuid alone would resolve any URL from
97
+ * anywhere as that app's object.
98
+ *
99
+ * Returns null for a URL no pattern claims, which is the honest outcome — §7.5
100
+ * says such a URL renders as a plain link, because it is one.
101
+ */
102
+ export function matchObjectUrl(url, patterns) {
103
+ const target = splitUrl(url);
104
+ if (!target)
105
+ return null;
106
+ for (const { pattern, kind } of patterns) {
107
+ const shape = splitUrl(pattern);
108
+ if (!shape)
109
+ continue;
110
+ if (shape.scheme !== target.scheme || shape.host !== target.host)
111
+ continue;
112
+ // The pattern's path up to its final segment: `/issue/` out of
113
+ // `/issue/<slug>-<d>`. Compared literally and at equal depth, so `/issues`
114
+ // never matches `/issue/…` and a nested route never matches a shallower one.
115
+ if (shape.segments.length !== target.segments.length)
116
+ continue;
117
+ const prefixMatches = shape.segments
118
+ .slice(0, -1)
119
+ .every((segment, i) => segment === target.segments[i]);
120
+ if (!prefixMatches)
121
+ continue;
122
+ const identifier = identifierFromRef(target.segments[target.segments.length - 1] ?? '');
123
+ if (!identifier)
124
+ continue;
125
+ return { identifier, ...(kind === undefined ? {} : { kind }) };
126
+ }
127
+ return null;
128
+ }
129
+ /**
130
+ * Scheme, host and path segments — the whole of the URL this grammar reads.
131
+ *
132
+ * Hand-parsed rather than through `new URL()`, which is an ambient global this
133
+ * package may not reach for: `tsconfig.base.json` sets `types: []` and a `lib`
134
+ * without DOM on purpose (ADR 0002 §4a), so that a published declaration cannot
135
+ * name a type one consumer has and another does not. The grammar is three
136
+ * fields; a parser for it is cheaper than the constraint it would break.
137
+ *
138
+ * Only `http` and `https` parse, which is belt-and-braces rather than the load
139
+ * -bearing rule: a `javascript:` or `data:` URL is already refused for having no
140
+ * `://` authority, and any other scheme is refused by the scheme comparison
141
+ * against a declared pattern, since every pattern an app publishes is `https`.
142
+ * A control confirmed that — relaxing this regex to accept any scheme failed no
143
+ * test. It stays because the cost is a character class and it makes the
144
+ * refusal local to the parser rather than a consequence of what apps happen to
145
+ * declare.
146
+ */
147
+ function splitUrl(raw) {
148
+ const match = /^(https?):\/\/([^/?#]+)([^?#]*)/i.exec(raw.trim());
149
+ if (!match)
150
+ return null;
151
+ const [, scheme, host, path] = match;
152
+ return {
153
+ scheme: scheme.toLowerCase(),
154
+ host: host.toLowerCase(),
155
+ segments: (path ?? '').split('/').filter(Boolean),
156
+ };
157
+ }
158
+ //# sourceMappingURL=objectUrl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"objectUrl.js","sourceRoot":"","sources":["../src/objectUrl.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/interop",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Render and act on another app's objects from its published NIP-89 manifest. The owner defines the projection; the consumer decides how it looks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -62,3 +62,21 @@ export {
62
62
  ACTION_EFFECTS,
63
63
  type UnsignedActionEvent,
64
64
  } from './projection.js'
65
+
66
+ /**
67
+ * RFC 0.5 §7's URL grammar — how an object is named in an address bar, and how
68
+ * a pasted link is matched back to one.
69
+ *
70
+ * Separate from the projection surface above because it answers a different
71
+ * question: those resolve an object you already hold, these turn a string
72
+ * somebody pasted into one you can hold.
73
+ */
74
+ export {
75
+ slugify,
76
+ objectRef,
77
+ identifierFromRef,
78
+ urlPatternsOf,
79
+ matchObjectUrl,
80
+ type UrlPattern,
81
+ type MatchedObjectUrl,
82
+ } from './objectUrl.js'
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Object URLs — RFC 0.5 §7, accepted 2026-09-03.
3
+ *
4
+ * https://<app>.estiva.app/<type>s a directory of that kind
5
+ * https://<app>.estiva.app/<type>/<slug>-<d> one object
6
+ *
7
+ * **Here rather than in each app** because it is wire-visible: §7.1's whole
8
+ * argument is that the URL one app puts in the address bar has to be resolvable
9
+ * by another, which is SPEC §10's test for something the protocol owns. Two
10
+ * implementations would disagree about what a slug may contain, and the
11
+ * disagreement would show up as a link that resolves in the app that wrote it
12
+ * and nowhere else.
13
+ *
14
+ * Peek's `src/lib/objectUrl.ts` is where `slugify`, `objectRef` and
15
+ * `identifierFromRef` were written (PEE-14) and they are moved rather than
16
+ * rewritten — an extraction that changes behaviour while every test still
17
+ * passes is the thing to guard against, so Peek's own cases come with them.
18
+ *
19
+ * Two properties from §7.2 do all the work:
20
+ *
21
+ * - **The uuid is the whole identity and is never truncated.** A consumer
22
+ * resolves `{"#d": ["<uuid>"]}` — one query, no index, and no service that
23
+ * can go down and take every published link with it.
24
+ * - **The slug is decorative.** Renaming the object changes it and the link
25
+ * still resolves, because a reader ignores everything between the `<type>`
26
+ * segment and the final uuid.
27
+ *
28
+ * That second sentence is the amended one. §7.2 used to say a consumer MUST
29
+ * ignore *everything* before the final uuid, which contradicted the paragraph
30
+ * above it and, read literally, resolves `evil.example.com/issue/<uuid>` as a
31
+ * Ship issue. The host and the `<type>` segment are exactly what select the app
32
+ * and the kind; only the slug is decoration. `matchObjectUrl` implements the
33
+ * corrected rule.
34
+ */
35
+
36
+ /**
37
+ * A v4 uuid at the very end of a ref.
38
+ *
39
+ * Anchored at the end rather than searched for, because §7.2's rule is
40
+ * positional: the identity is the tail, and everything before it is a human
41
+ * label that may itself contain hyphens, digits and hex.
42
+ */
43
+ const TRAILING_UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
44
+
45
+ /**
46
+ * Slugs are cut here. Long enough to stay recognisable in a chat client's link
47
+ * preview, short enough that the uuid is not pushed off the end of a rendered
48
+ * URL — which is the only thing in the ref that carries meaning.
49
+ */
50
+ const SLUG_MAX_LENGTH = 60
51
+
52
+ /**
53
+ * A title reduced to URL-safe words. Decorative by contract: nothing resolves
54
+ * through it, so it is free to be lossy — accents folded, punctuation dropped,
55
+ * runs collapsed.
56
+ */
57
+ export function slugify(title: string): string {
58
+ return title
59
+ .normalize('NFKD')
60
+ .replace(/[̀-ͯ]/g, '')
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9]+/g, '-')
63
+ .replace(/^-+|-+$/g, '')
64
+ .slice(0, SLUG_MAX_LENGTH)
65
+ .replace(/-+$/g, '')
66
+ }
67
+
68
+ /** `<slug>-<identifier>`, or the bare identifier when the title slugs to nothing. */
69
+ export function objectRef(identifier: string, title?: string): string {
70
+ const slug = title ? slugify(title) : ''
71
+ return slug ? `${slug}-${identifier}` : identifier
72
+ }
73
+
74
+ /**
75
+ * The identity out of a ref, or `null` if it carries none.
76
+ *
77
+ * Lowercased on the way out: the relay's `d` values are lowercase uuids, and a
78
+ * link that has been through a system which upper-cased it should still resolve
79
+ * rather than silently miss.
80
+ */
81
+ export function identifierFromRef(ref: string): string | null {
82
+ const match = TRAILING_UUID_RE.exec(ref)
83
+ return match ? match[0].toLowerCase() : null
84
+ }
85
+
86
+ /**
87
+ * A URL shape an app says it serves — RFC 0.5 §7.5's `urls`, read off the
88
+ * manifest event the way `web` is.
89
+ *
90
+ * `web` is outbound (given an object, build a link); this is inbound (given a
91
+ * link, recover the object). Usually the same strings, separate fields because
92
+ * they answer different questions — and because an app that changes its routes
93
+ * still has to read the links it published under the old ones.
94
+ */
95
+ export interface UrlPattern {
96
+ /** e.g. `https://ship.estiva.app/issue/<slug>-<d>` */
97
+ pattern: string
98
+ /**
99
+ * The kind this shape names, when the app said so.
100
+ *
101
+ * §7.2 says the kind comes from the path segment — but `/issue/` means 30851
102
+ * only *to the app serving it*, and a consumer has never heard of "issue".
103
+ * So an app MAY name the kind alongside the pattern. When it does not, a
104
+ * consumer falls back to the kinds the manifest declares it handles, which is
105
+ * sound because no `d` is reused under two kinds — measured across all 280
106
+ * production records at §7's acceptance.
107
+ */
108
+ kind?: number
109
+ }
110
+
111
+ /** The `urls` shapes a manifest event declares. */
112
+ export function urlPatternsOf(event: { tags: string[][] }): UrlPattern[] {
113
+ const out: UrlPattern[] = []
114
+ for (const tag of event.tags) {
115
+ if (tag[0] !== 'urls' || !tag[1]) continue
116
+ const kind = tag[2] ? Number(tag[2]) : undefined
117
+ out.push({ pattern: tag[1], ...(Number.isInteger(kind) ? { kind } : {}) })
118
+ }
119
+ return out
120
+ }
121
+
122
+ /** What a pasted URL turned out to name. */
123
+ export interface MatchedObjectUrl {
124
+ identifier: string
125
+ /** Present only when the matched pattern named one. */
126
+ kind?: number
127
+ }
128
+
129
+ /**
130
+ * Match a pasted URL against one app's declared shapes.
131
+ *
132
+ * **The host and the `<type>` segment are significant; the slug is not.** That
133
+ * is §7.2 as amended, and it is the whole of the security story here: a
134
+ * consumer that matched on the trailing uuid alone would resolve any URL from
135
+ * anywhere as that app's object.
136
+ *
137
+ * Returns null for a URL no pattern claims, which is the honest outcome — §7.5
138
+ * says such a URL renders as a plain link, because it is one.
139
+ */
140
+ export function matchObjectUrl(url: string, patterns: UrlPattern[]): MatchedObjectUrl | null {
141
+ const target = splitUrl(url)
142
+ if (!target) return null
143
+
144
+ for (const { pattern, kind } of patterns) {
145
+ const shape = splitUrl(pattern)
146
+ if (!shape) continue
147
+ if (shape.scheme !== target.scheme || shape.host !== target.host) continue
148
+
149
+ // The pattern's path up to its final segment: `/issue/` out of
150
+ // `/issue/<slug>-<d>`. Compared literally and at equal depth, so `/issues`
151
+ // never matches `/issue/…` and a nested route never matches a shallower one.
152
+ if (shape.segments.length !== target.segments.length) continue
153
+ const prefixMatches = shape.segments
154
+ .slice(0, -1)
155
+ .every((segment, i) => segment === target.segments[i])
156
+ if (!prefixMatches) continue
157
+
158
+ const identifier = identifierFromRef(target.segments[target.segments.length - 1] ?? '')
159
+ if (!identifier) continue
160
+ return { identifier, ...(kind === undefined ? {} : { kind }) }
161
+ }
162
+ return null
163
+ }
164
+
165
+ /**
166
+ * Scheme, host and path segments — the whole of the URL this grammar reads.
167
+ *
168
+ * Hand-parsed rather than through `new URL()`, which is an ambient global this
169
+ * package may not reach for: `tsconfig.base.json` sets `types: []` and a `lib`
170
+ * without DOM on purpose (ADR 0002 §4a), so that a published declaration cannot
171
+ * name a type one consumer has and another does not. The grammar is three
172
+ * fields; a parser for it is cheaper than the constraint it would break.
173
+ *
174
+ * Only `http` and `https` parse, which is belt-and-braces rather than the load
175
+ * -bearing rule: a `javascript:` or `data:` URL is already refused for having no
176
+ * `://` authority, and any other scheme is refused by the scheme comparison
177
+ * against a declared pattern, since every pattern an app publishes is `https`.
178
+ * A control confirmed that — relaxing this regex to accept any scheme failed no
179
+ * test. It stays because the cost is a character class and it makes the
180
+ * refusal local to the parser rather than a consequence of what apps happen to
181
+ * declare.
182
+ */
183
+ function splitUrl(raw: string): { scheme: string; host: string; segments: string[] } | null {
184
+ const match = /^(https?):\/\/([^/?#]+)([^?#]*)/i.exec(raw.trim())
185
+ if (!match) return null
186
+ const [, scheme, host, path] = match
187
+ return {
188
+ scheme: scheme.toLowerCase(),
189
+ host: host.toLowerCase(),
190
+ segments: (path ?? '').split('/').filter(Boolean),
191
+ }
192
+ }