@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 +41 -0
- 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,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
|
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
|
+
}
|