@estiva-app/interop 0.9.1 → 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,47 @@ 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
+
9
50
  ## 0.9.1 — 2026-09-03
10
51
 
11
52
  **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,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.1",
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
+ }