@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 +76 -0
- package/README.md +109 -5
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/objectUrl.d.ts +97 -0
- package/dist/objectUrl.d.ts.map +1 -0
- package/dist/objectUrl.js +158 -0
- package/dist/objectUrl.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +18 -0
- package/src/objectUrl.ts +192 -0
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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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.
|
|
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'
|
package/src/objectUrl.ts
ADDED
|
@@ -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
|
+
}
|