@estiva-app/interop 0.9.1 → 0.10.1

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,68 @@ 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.1 — 2026-09-03
10
+
11
+ **`matchObjectUrl` matched no fragment route at all.** The parser's path capture
12
+ stopped at the `#`, so `https://ship.estiva.app/#/issue/<uuid>` reduced to no
13
+ segments and matched nothing — including patterns declaring exactly that shape.
14
+
15
+ That is not a corner. Ship served fragment routes until SHI-16 and declares them
16
+ still, so that links already sitting in other people's messages resolve rather
17
+ than rendering as plain text for ever. A consumer on 0.10.0 would have silently
18
+ failed every one of them.
19
+
20
+ Found by a production probe before any consumer was built on it, which is the
21
+ argument for probing the mechanism rather than the fixture.
22
+
23
+ - Path and fragment segments are concatenated rather than swapped, because an
24
+ app may be served under a sub-path *and* use a fragment — Ship's own dev URL
25
+ is `localhost:5190/ship/#/…`, where both halves carry meaning.
26
+ - A fragment route and a path route stay distinct shapes. Collapsing them would
27
+ let a pattern for `/issue/<slug>-<d>` claim `/#/issue/<d>`, and an app that
28
+ means different things by the two would resolve the wrong object.
29
+
30
+ ## 0.10.0 — 2026-09-03
31
+
32
+ **RFC 0.5 §7's URL grammar, which was accepted with amendments today.** No
33
+ existing manifest changes meaning; this adds a shape an app MAY declare and the
34
+ matching a consumer needs to read it.
35
+
36
+ ### Added
37
+
38
+ - **`slugify`, `objectRef`, `identifierFromRef`** — `<type>/<slug>-<d>`, built
39
+ and read. **Moved from Peek** (`src/lib/objectUrl.ts`, PEE-14) rather than
40
+ rewritten, with its cases, because an extraction that changes behaviour while
41
+ every test still passes is the thing to guard against.
42
+
43
+ Here rather than in each app because §7.1's argument is that the URL one app
44
+ puts in the address bar must be resolvable by another — SPEC §10's test for
45
+ something the protocol owns. Two implementations would disagree about what a
46
+ slug may contain, and the disagreement shows up as a link that resolves only
47
+ in the app that wrote it. Ship was about to grow the second copy (SHI-16).
48
+
49
+ - **`urlPatternsOf`, `matchObjectUrl`, `UrlPattern`, `MatchedObjectUrl`** —
50
+ §7.5's inbound half: given a pasted link and the shapes apps publish, recover
51
+ which object it names. A URL no pattern claims returns `null`, which is the
52
+ honest outcome — it is a link, and nothing should claim otherwise.
53
+
54
+ ### The amendment this implements
55
+
56
+ §7.2 said a consumer MUST ignore *everything* before the final uuid, which
57
+ contradicted the paragraph above it and, read literally, resolves
58
+ `evil.example.com/issue/<uuid>` as a Ship issue. **The host and the `<type>`
59
+ segment select the app and the kind; only the slug is decoration**, and
60
+ `matchObjectUrl` enforces that. It is the difference between a resolver and a
61
+ gadget for rendering an attacker's chosen object inside your app.
62
+
63
+ ### One thing §7.5 leaves open, decided here
64
+
65
+ A bare pattern tells a consumer *which app*, not which kind: `/issue/` means
66
+ `30851` only to the app serving it. So `urls` MAY carry the kind as a third tag
67
+ element. When it does not, a consumer falls back to the kinds that manifest
68
+ declares it handles — sound because no `d` is reused under two kinds, measured
69
+ across all 280 production records at §7's acceptance.
70
+
9
71
  ## 0.9.1 — 2026-09-03
10
72
 
11
73
  **No behaviour change. The README was wrong, and this is the release that fixes
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,CAyB3F"}
@@ -0,0 +1,181 @@
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
+ // A fragment route and a path route are different shapes, not one.
113
+ if (shape.fragmented !== target.fragmented)
114
+ continue;
115
+ // The pattern's path up to its final segment: `/issue/` out of
116
+ // `/issue/<slug>-<d>`. Compared literally and at equal depth, so `/issues`
117
+ // never matches `/issue/…` and a nested route never matches a shallower one.
118
+ if (shape.segments.length !== target.segments.length)
119
+ continue;
120
+ const prefixMatches = shape.segments
121
+ .slice(0, -1)
122
+ .every((segment, i) => segment === target.segments[i]);
123
+ if (!prefixMatches)
124
+ continue;
125
+ const identifier = identifierFromRef(target.segments[target.segments.length - 1] ?? '');
126
+ if (!identifier)
127
+ continue;
128
+ return { identifier, ...(kind === undefined ? {} : { kind }) };
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * Scheme, host and path segments — the whole of the URL this grammar reads.
134
+ *
135
+ * Hand-parsed rather than through `new URL()`, which is an ambient global this
136
+ * package may not reach for: `tsconfig.base.json` sets `types: []` and a `lib`
137
+ * without DOM on purpose (ADR 0002 §4a), so that a published declaration cannot
138
+ * name a type one consumer has and another does not. The grammar is three
139
+ * fields; a parser for it is cheaper than the constraint it would break.
140
+ *
141
+ * Only `http` and `https` parse, which is belt-and-braces rather than the load
142
+ * -bearing rule: a `javascript:` or `data:` URL is already refused for having no
143
+ * `://` authority, and any other scheme is refused by the scheme comparison
144
+ * against a declared pattern, since every pattern an app publishes is `https`.
145
+ * A control confirmed that — relaxing this regex to accept any scheme failed no
146
+ * test. It stays because the cost is a character class and it makes the
147
+ * refusal local to the parser rather than a consequence of what apps happen to
148
+ * declare.
149
+ */
150
+ function splitUrl(raw) {
151
+ const match = /^(https?):\/\/([^/?#]+)([^?#]*)(?:\?[^#]*)?(?:#(.*))?$/i.exec(raw.trim());
152
+ if (!match)
153
+ return null;
154
+ const [, scheme, host, path, fragment] = match;
155
+ /*
156
+ A fragment route counts as path.
157
+
158
+ Ship served `#/issue/<d>` until SHI-16 and still declares those shapes, so
159
+ that links already sitting in other people's messages resolve rather than
160
+ rendering as plain text for ever. A parser that stopped at the `#` — this
161
+ one did — matched none of them, which a production probe caught before any
162
+ consumer was built on it.
163
+
164
+ Segments from the path and the fragment are concatenated rather than
165
+ swapped, because an app may be served under a sub-path *and* use a
166
+ fragment: Ship's own dev URL is `localhost:5190/ship/#/…`, where both
167
+ halves carry meaning.
168
+
169
+ `fragmented` is kept so the two do not collapse into each other. Without it
170
+ a pattern for `/issue/<slug>-<d>` would also claim `/#/issue/<d>`, and an
171
+ app that means different things by the two would resolve the wrong object.
172
+ */
173
+ const fragmentPath = fragment && fragment.startsWith('/') ? fragment : '';
174
+ return {
175
+ scheme: scheme.toLowerCase(),
176
+ host: host.toLowerCase(),
177
+ segments: [...(path ?? '').split('/'), ...fragmentPath.split('/')].filter(Boolean),
178
+ fragmented: fragmentPath !== '',
179
+ };
180
+ }
181
+ //# 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.1",
3
+ "version": "0.10.1",
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,215 @@
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
+ // A fragment route and a path route are different shapes, not one.
149
+ if (shape.fragmented !== target.fragmented) continue
150
+
151
+ // The pattern's path up to its final segment: `/issue/` out of
152
+ // `/issue/<slug>-<d>`. Compared literally and at equal depth, so `/issues`
153
+ // never matches `/issue/…` and a nested route never matches a shallower one.
154
+ if (shape.segments.length !== target.segments.length) continue
155
+ const prefixMatches = shape.segments
156
+ .slice(0, -1)
157
+ .every((segment, i) => segment === target.segments[i])
158
+ if (!prefixMatches) continue
159
+
160
+ const identifier = identifierFromRef(target.segments[target.segments.length - 1] ?? '')
161
+ if (!identifier) continue
162
+ return { identifier, ...(kind === undefined ? {} : { kind }) }
163
+ }
164
+ return null
165
+ }
166
+
167
+ /**
168
+ * Scheme, host and path segments — the whole of the URL this grammar reads.
169
+ *
170
+ * Hand-parsed rather than through `new URL()`, which is an ambient global this
171
+ * package may not reach for: `tsconfig.base.json` sets `types: []` and a `lib`
172
+ * without DOM on purpose (ADR 0002 §4a), so that a published declaration cannot
173
+ * name a type one consumer has and another does not. The grammar is three
174
+ * fields; a parser for it is cheaper than the constraint it would break.
175
+ *
176
+ * Only `http` and `https` parse, which is belt-and-braces rather than the load
177
+ * -bearing rule: a `javascript:` or `data:` URL is already refused for having no
178
+ * `://` authority, and any other scheme is refused by the scheme comparison
179
+ * against a declared pattern, since every pattern an app publishes is `https`.
180
+ * A control confirmed that — relaxing this regex to accept any scheme failed no
181
+ * test. It stays because the cost is a character class and it makes the
182
+ * refusal local to the parser rather than a consequence of what apps happen to
183
+ * declare.
184
+ */
185
+ function splitUrl(raw: string): { scheme: string; host: string; segments: string[]; fragmented: boolean } | null {
186
+ const match = /^(https?):\/\/([^/?#]+)([^?#]*)(?:\?[^#]*)?(?:#(.*))?$/i.exec(raw.trim())
187
+ if (!match) return null
188
+ const [, scheme, host, path, fragment] = match
189
+
190
+ /*
191
+ A fragment route counts as path.
192
+
193
+ Ship served `#/issue/<d>` until SHI-16 and still declares those shapes, so
194
+ that links already sitting in other people's messages resolve rather than
195
+ rendering as plain text for ever. A parser that stopped at the `#` — this
196
+ one did — matched none of them, which a production probe caught before any
197
+ consumer was built on it.
198
+
199
+ Segments from the path and the fragment are concatenated rather than
200
+ swapped, because an app may be served under a sub-path *and* use a
201
+ fragment: Ship's own dev URL is `localhost:5190/ship/#/…`, where both
202
+ halves carry meaning.
203
+
204
+ `fragmented` is kept so the two do not collapse into each other. Without it
205
+ a pattern for `/issue/<slug>-<d>` would also claim `/#/issue/<d>`, and an
206
+ app that means different things by the two would resolve the wrong object.
207
+ */
208
+ const fragmentPath = fragment && fragment.startsWith('/') ? fragment : ''
209
+ return {
210
+ scheme: scheme.toLowerCase(),
211
+ host: host.toLowerCase(),
212
+ segments: [...(path ?? '').split('/'), ...fragmentPath.split('/')].filter(Boolean),
213
+ fragmented: fragmentPath !== '',
214
+ }
215
+ }