Hadron URN compose / parse / normalize for JavaScript & TypeScript.
Paired with urn-lib-go. Both
implementations run the same conformance corpus (fixtures/corpus.json) —
the corpus is the contract, so the two languages cannot drift. This exists to
replace the copy-pasted URN parsers that had already drifted across
hadron-server / portal / docs / cli (hadron-server#239, #693).
Increment 1 — v1-parity core. This release ports the pure, self-contained
slice of hadron-server/src/lib/urn.ts verbatim, behind the shared corpus:
- scheme —
CANONICAL_SCHEME,LEGACY_SCHEME,hasSchemePrefix,normalizeScheme - registry — the locked type registry (
URN_TYPES,ROLE_MARKERS,RESERVED_SLUGS, …) - normalize —
normalizeUrnForLookup,legacyMemoryUrnToCanonical,agentSlugFromUrn - slug —
validateAtomShape,validateUserSlug,validateOrgSlug(requires a dotted root, #692),validateUserHandle(dot-free, #692),deriveSlugFromName - legacy —
parseUrnInput,formatUrn,validateUrnType(the pre-021 surface) - parser —
parseUrn(the canonical-form parser, D11 cats 1 + 4),isParserCanonical,toParserCanonical - compose —
formatCanonicalUrn,composeNodeUrn,composeEdgeUrn - display —
parseDisplayUrn,DISPLAY_URN_TYPES(the tolerant spec-010 chip parser/registry — separate from the strict canonical parser; falls back tounknownfor unregistered display kinds) - errors —
UrnParseError+ the machine-stableUrnParseErrorReasonunion
Grammar-v2 flat forms (hadron-server#694) are ported: parseUrnV2 /
composeUrnV2 / isFlatV2 over hrn:<type>:<root>[:<segment>...] (single
colon, no sigil), additive to the v1 surface.
Per-entity v2 shapes (hadron-server#696, decision D-2026-07-15-006) are ported too:
- secret —
hrn:secret:<root>:<name>, org/user root + exactly one name atom (the v1app:/memory:markers have no v2 equivalent and are rejected). - apprun —
hrn:apprun:<root>:<app>:<run-id>(fixed arity). - noderev —
hrn:noderev:<root>:<mem>:<loc...>:<rev>, end-anchored (last atom is the revision id); decompose withparseNodeRevUrnV2. - node / edge —
hrn:<type>:<root>:<mem>:<loc...>(a memory + at least one loc atom); the loc is an opaque terminal (an edge loc is never re-split intosource:target). #datafragment —<node-or-apprun-urn>#data(node-data is a fragment of its parent, not a standalone type).composeDataFragmentV2always emits a canonicalhrn:URN, even from a legacyurn:-scheme parent.
#696 demoted the v1 node-part type words (data, condition) to
fragments. That changed the spelling, not the meaning — so parseUrn maps a
fragmented flat-v2 URN onto the fragment's v1 type word:
hrn:node:<root>:<mem>:<loc>#data parses as type: 'data' over the parent's
path, which is exactly the v1 hrn:data:<root>::<mem>::<loc> reading of the
same resource. parserCanonical keeps the v2 form (fragment included), so it
round-trips. A fragment on a v2-only parent (apprun, worker) has no v1
type equivalent and keeps its unknown-type error; reach for parseUrnV2 when
you need the parent type and fragment as separate fields.
Typed helpers: composeSecretUrnV2, composeAppRunUrnV2, composeNodeRevUrnV2,
composeDataFragmentV2, and parseNodeRevUrnV2.
Not yet ported (later increments, gated by the same corpus): legacy chain→flat normalization + the stored alias map (hadron-server#697) and the unified principal-pool enforcement (hadron-server#692).
ParsedUrn.pathSegments is a raw split whose shape follows the input's
grammar (#12). v1 input is split on ::, so a segment may carry an internal
:; flat-v2 input is split on the single :, so every atom is its own element.
The two spellings of one resource therefore differ:
parseUrn('hrn:node:acme.com::specs::cor:urn').pathSegments // ['acme.com', 'specs', 'cor:urn']
parseUrn('hrn:node:acme.com:specs:cor:urn').pathSegments // ['acme.com', 'specs', 'cor', 'urn']This affects every type whose v1 form permits an internal : inside a segment —
memory (valued role markers like app-user:<id>), node, and edge. org,
user, agent, app, secret and asset are identical under both grammars.
splitNodeUrn / splitEdgeUrn normalize across grammars and report a #data
fragment separately, so a caller never has to know which grammar it was handed:
splitNodeUrn('hrn:node:acme.com::specs::cor:urn'); // { memoryUrn: 'acme.com:specs', loc: 'cor:urn' }
splitNodeUrn('hrn:node:acme.com:specs:cor:urn'); // { memoryUrn: 'acme.com:specs', loc: 'cor:urn' }
splitNodeUrn('hrn:node:acme.com:specs:cor:urn#data'); // { …, loc: 'cor:urn', fragment: 'data' }An edge loc is an opaque terminal — splitEdgeUrn never re-splits it into
source:target. Use parseUrnV2 when you want the v2 root / segments /
fragment as separate fields.
When a fragment is the only thing wrong with a URN, parseUrn says so with
the fragment-unsupported reason rather than leaking the v1 parser's complaint
about the # character:
parseUrn('hrn:node:acme.com:mem:loc#bogus'); // UrnParseError, reason 'fragment-unsupported'
parseUrn('hrn:worker:acme.com:eng:iris#data'); // 'unknown-type' — worker has no v1 form either wayThe test is whether removing the fragment would make the input parse. If it wouldn't, the original reason stands, so fixing what the error names always moves you forward.
Both return NodeLikeUrnParts (node and edge share one shape). Under v1 the
memory may be multi-segment, and the terminal :: segment is the loc:
splitNodeUrn('hrn:node:mm.org::coding-app::coding-agent::app-mem::a:b');
// { memoryUrn: 'mm.org:coding-app:coding-agent:app-mem', loc: 'a:b' }Both are self-validating: they reject an unregistered fragment word and a
fragment on an edge (only node/apprun may parent one), so they are never
more permissive than parseUrn for the same input.
Known gap: there is no splitMemoryUrn yet. v2 leaves mem arity
unconstrained, so hrn:mem:<root>:<a>:<b>:<c> cannot be split into containers
vs leaf until the v2 spec pins it (hadron-server#698).
import { normalizeUrnForLookup, validateOrgSlug, UrnParseError } from '@hadron-memory/urn-lib-js';
normalizeUrnForLookup('acme.com::specs::cor:urn'); // 'acme.com:specs:cor:urn'
try {
validateOrgSlug('Acme.com');
} catch (e) {
if (e instanceof UrnParseError) console.log(e.reason); // 'slug-not-lowercase'
}fixtures/corpus.json is the source of truth for behavior. Each case is
{ fn, in: [args], out?, throws? } — out is the expected return, throws is
the expected UrnParseError.reason, and neither means a void call that must
not throw. test/corpus.test.ts runs every case against this implementation;
urn-lib-go runs the identical file. Add behavior by adding a corpus case,
not by editing a test in one language.
npm is the package manager of record. package-lock.json is the only
lockfile in the tree — don't add a second one.
npm install
npm test # runs the corpus
npm run buildhrn:ticker:<root>:<app-slug>:<ticker-slug> and
hrn:agenttask:<root>:<app-slug>:<task-id> each have exactly two segments after
the root. Use composeTickerUrnV2 with the ticker's permanent derived slug and
composeAgentTaskUrnV2 with the task's id. Both parse through parseUrnV2,
render their own display badge, and normalize a legacy urn: prefix to hrn:.
They have no v1 equivalent; parseUrn rejects them with unknown-type.
Task ids follow the shared URN atom rule (letters/digits, dots, underscores and hyphens, at most 64 characters). A 32-hex or cuid id is valid in the task-id leaf; root-name registration restrictions do not apply to that leaf.
validateOrgSlug admits dotted and undotted organization names. Both it and
validateUserHandle reject 32-hex/cuid-shaped roots (root-id-shaped) and the
reserved root words hrn/urn (reserved-word-slug). New handles remain
dot-free. Bare-name, lowercase, charset and length checks remain in place.
This is create/rename policy. Generic slug validation, URN parsing and
composition remain lenient so existing stored URNs and ID-shaped task leaves
still round-trip. The obsolete org-root-not-dotted reason is retired.
MIT © Baragaun, Inc.