Seed Protocol

@seedprotocol/archive

Reference for @seedprotocol/archive: export a published item as a BagIt bag, build reproducible bags, verify a bag offline or against its archive anchor on EAS, and lay a bag out on Arweave with a path manifest.

@seedprotocol/archive packages a published item as a BagIt bag: its Html, its files and its attested values, with SHA-256 and SHA-512 checksum manifests and Dublin Core and PREMIS metadata, so an archive can validate and ingest it without Seed code. It also checks bags, offline or against an archive anchor on EAS, and reads and writes the path manifests that lay a bag out on Arweave.

npm install @seedprotocol/archive
ImportWhat it hasNeeds the SDK
@seedprotocol/archiveexportArchive, verifyArchive, verifyArchiveAgainstChain, the path manifest functions, and the bag, document and metadata building blocksYes
@seedprotocol/archive/verifyverifyPublishedArchive, findArchiveAnchors, decodeArchiveAnchorData and the two schema UIDs. Reads EAS and a gateway with fetch; runs in browsers and NodeNo
@seedprotocol/archive/nodewriteBagDirectory, readBagDirectory (Node files)No

It depends on @seedprotocol/arweave, @seedprotocol/eas and @seedprotocol/query at its own exact version, and lists @seedprotocol/sdk as an optional peer at the same version, so npm install @seedprotocol/archive doesn't install the SDK. @seedprotocol/publish depends on it for anchorArchive.

The main entry imports @seedprotocol/sdk, and without the SDK installed import('@seedprotocol/archive') fails with ERR_MODULE_NOT_FOUND (Cannot find package '@seedprotocol/sdk'). That includes the functions that don't use the SDK: createBag, validateBag, verifyArchive, readBagInfo, the path manifest functions, parseArchiveDocument, dublinCoreXml and premisXml. Without the SDK, only @seedprotocol/archive/verify (checking a published bag over the network) and @seedprotocol/archive/node load. To build or check bags offline, install the SDK beside archive.

Export a published item

exportArchive builds the bag for an item's published state, in memory.

function exportArchive(
  item: ExportableItem | ArchiveSeedRef,
  options?: ExportArchiveOptions,
): Promise<ExportArchiveResult>

item is an Item, as Item.create returns, or, for a reproducible bag only, an ArchiveSeedRef: { seedUid, modelName, schemaName? }. The declared ExportableItem type doesn't accept the IItem<any> that Item.find returns (its getService() type differs), so cast those: exportArchive(post as ExportableItem), with ExportableItem imported as a type from @seedprotocol/archive.

An ordinary export first waits for the writes pending in the outbox on the item and the items its publish covers, as publish() does, then reads this device's rows and files.

OptionTypeDefaultDescription
fetchbooleantrueDownload Images, Files and Html that aren't on this device. With false, only local files go in the bag and the others are listed in omitted. Refused with reproducible
reproduciblebooleanfalseBuild the reproducible bag from EAS and Arweave instead of this device's rows
asOfnumberthe seed's latest stateReproducible only. The time the bag covers, in whole unix seconds
expectedAttestationsreadonly string[]noneReproducible only. Attestation UIDs that must be on EAS before the bag is built, such as the ones a publish just made
sourceOrganizationstringnoneThe Source-Organization line of bag-info.txt, such as the publication's name. Refused with reproducible
baggingDateDatenowThe Bagging-Date line of bag-info.txt, and the time of the bag's own PREMIS events. Refused with reproducible

It resolves to { bag, omitted, arweaveTransactions }:

FieldTypeDescription
bagBagbag.files is an array of { path: string; bytes: Uint8Array } with /-separated paths from the bag's base directory
omittedArchiveOmission[]{ property, reason } for each file that couldn't be read or downloaded, and each Html reference to an Arweave transaction that isn't in the bag (it keeps its gateway URL). These don't fail an ordinary export, and data/item.json lists them too. Always empty for a reproducible bag, which refuses instead
arweaveTransactionsRecord<string, string>Reproducible only: each bag path whose exact bytes a published Arweave transaction already holds, mapped to its transaction ID. {} for an ordinary export. See A bag on Arweave

What the bag contains

PathContents
data/index.htmlThe item's Html, with each reference to a file in the bag (a gateway URL or a seed:property/ placeholder) replaced by the file's relative path, so it opens offline. Only those attribute values change. A model with several Html properties gets data/<property>.html for each instead
data/source/<property>.htmlEach Html property byte for byte as its Arweave transaction holds it
data/assets/<sha256>.<ext>The item's Images and Files, and the images its Html references
data/item.jsonThe Seed and Version UIDs, model, chain and attester, and each property's value, attestation UID, attestation time and Arweave transaction. A published Relation or List is recorded as the related seeds' UIDs
bagit.txt, bag-info.txtbag-info.txt holds Seed-Uid, Seed-Version-Uid, Seed-Model, EAS-Chain-Id, EAS-Attester, Seed-Bag-Times, Payload-Oxum and, in a reproducible bag, Seed-Bag-Format and Seed-Bag-As-Of
manifest-sha256.txt, manifest-sha512.txtPayload checksums
metadata/dc.xmlDublin Core (oai_dc), filled from the Html's h-entry markup when it has some (p-name, p-summary, p-author, dt-published, u-url); otherwise identifiers, type and the Version's date only
metadata/premis.xmlPREMIS 3.0: the first live Version as the creation event, later Versions and patches as modifications, and the bag's own digest and packaging events
VERIFY.txtThe shasum commands to check the bag by hand, the Seed and Version UIDs and the Version's EAS explorer link. A reproducible bag's copy also gives the exportArchive call that rebuilds it
tagmanifest-sha256.txt, tagmanifest-sha512.txtChecksums of every file outside data/. The SHA-256 of tagmanifest-sha256.txt commits to the whole bag; it's the hash an archive anchor records

Json storage seeds and the content of related items aren't exported.

Every time in the bag says whose clock it came from, in each PREMIS event's detail, as attestedAtClock next to each attestedAt in data/item.json, and in Seed-Bag-Times: chain-block is the attestation's block time as EAS reports it; local-record is the time stored on the exporting device, which for an attestation that device published may be its own clock; export-device is the exporting device's clock, for an ordinary bag's own events.

Errors

It throws ArchiveExportError, with code:

codeCause
'not_published'No live published Version: never published, unpublished, or, with asOf, none live at that time. Message: Post <seedLocalId> has no live published version
'unpublished_changes'Ordinary export only: a property holds a saved value that isn't published yet. Publish first; the bag holds only what attestations back
'not_synced'Reproducible only: EAS doesn't report the seed, a Version of it, everything in expectedAttestations, or (without asOf) an attestation this device made. Retry once the indexer catches up
'chain_unavailable'Reproducible only: the EAS indexer couldn't be read
'incomplete'Reproducible only: the bag would leave something out, such as a file no gateway served
'invalid_options'fetch: false, sourceOrganization or baggingDate with reproducible; asOf or expectedAttestations without it, or an asOf that isn't a positive whole number; an ArchiveSeedRef without reproducible; a model that isn't loaded (Model Nope isn't loaded; load its schema first)

Example

On Node, writeBagDirectory writes the bag to a directory, which must be empty or not exist yet. In a browser, zip bag.files or write them to OPFS.

import { exportArchive, type ExportableItem } from '@seedprotocol/archive'
import { writeBagDirectory } from '@seedprotocol/archive/node'

const { bag, omitted } = await exportArchive(post as ExportableItem, { sourceOrganization: 'The Daily Example' })
await writeBagDirectory(bag, './post-bag')
for (const { property, reason } of omitted) console.warn(`${property}: ${reason}`)

Reproducible bags

With reproducible: true, every client builds the same bytes for a seed at one time, so the same tagmanifest-sha256.txt hash. This is the bag to anchor, sign or publish. It reads EAS and Arweave and no rows, so a device that holds nothing for the seed can name it by UID; only the schema has to be loaded.

import { exportArchive } from '@seedprotocol/archive'

const { bag, arweaveTransactions } = await exportArchive(
  { seedUid: '0xa313944ea62a86bfbb9db2d9be984d7ad8641eead88d5769500de5f6c3bdb3eb', modelName: 'Post', schemaName: 'blog' },
  { reproducible: true, asOf: 1793799143 },
)

The bag depends on these inputs and no others:

  • The time it covers, asOf, recorded as Seed-Bag-As-Of in bag-info.txt and asOf in data/item.json. The bag holds the newest Version live then and the property values current then, so a later patch, Version, retraction or revocation doesn't change it. Without asOf it covers the latest state: the time of the last attestation or revocation on the newest live Version's properties and its storage seeds, so the bag follows the seed.
  • The seed's attestation and its Versions, as EAS reports them. Only the seed attester's Versions and the Version attester's property attestations count, since anyone can attest with any refUID.
  • Each property's attestation current at asOf by sync's rule: the newest live one, and no value when all were revoked.
  • The bytes of the Arweave transactions those attestations name. Files are always read from Arweave, because a local copy can differ from what was uploaded, and are typed by their bytes (application/octet-stream when the bytes don't show a type).
  • The model name and each attested property's key and data type. Not the schema's name: a schema named differently gives the same bag. A property added later changes nothing; renaming an attested property or changing its type does.
  • The chain and the format version, Seed-Bag-Format (1).

The export time, device clocks, local file names, the configured gateway, unpublished edits and sourceOrganization never reach the bytes. Every time is chain-block, and PREMIS names files on Arweave as ar://<tx>. History stops at the current Version and leaves revocations out: check revocation on EAS, not in the bag.

asOf says what to read, not that EAS has it all. Soon after a publish, pass the attestation UIDs the publish made as expectedAttestations; without them a bag built while the indexer is part way through the publish is valid and incomplete. A not_synced refusal means retry later. To read what EAS held for a seed at a time without building a bag, use query's readSeedAtTime.

An ordinary bag and a reproducible bag of the same item differ: the ordinary one carries Seed-Bag-Times: local-record, Seed-Schema, any Source-Organization, the export date, and EAS-Attester in lowercase where the reproducible bag has the checksummed address. Only the reproducible bag's hash can be compared with an anchor or a rebuild.

Verify a bag

verifyArchive checks a bag with no rows and no network. Each check is reported on its own, with no overall verdict.

function verifyArchive(bag: Bag, options?: { expectedTagmanifestSha256?: string }): ArchiveVerification
FieldDescription
fixity{ status, detail, errors }: the bag against its own manifests. A changed payload file gives failed, with one error per manifest and one for Payload-Oxum. A bag without tagmanifest-sha256.txt fails
anchor{ status, detail }: the SHA-256 of tagmanifest-sha256.txt against expectedTagmanifestSha256 (hex, with or without 0x, any case). not_checked without one; failed names both hashes
tagmanifestSha256The bag's own hash, lowercase hex

status is 'passed', 'failed' or 'not_checked'. fixity alone says the bag is intact, not that it's the bag you expect: anyone who changes a file can rewrite the manifests too. Only anchor, against a hash from outside the bag (an archive anchor, a signature, a rebuild), ties the bag to its source. A bag whose payload was changed without touching the tag manifest still passes anchor while fixity fails.

import { readBagInfo, verifyArchive } from '@seedprotocol/archive'

const { fixity, anchor } = verifyArchive(bag, { expectedTagmanifestSha256: anchorHash })
const asOf = readBagInfo(bag).get('Seed-Bag-As-Of')

readBagInfo(bag) returns bag-info.txt as a Map of label to value, with the first value of a repeated label.

The commands in VERIFY.txt do the same check without code: shasum -a 256 -c tagmanifest-sha256.txt and shasum -a 256 -c manifest-sha256.txt in the bag's directory, then shasum -a 256 tagmanifest-sha256.txt to compare with an outside hash. verifyInstructions({ seedUid, versionUid, explorerUrl?, asOf? }) returns that text.

verifyArchiveAgainstChain

verifyArchiveAgainstChain(bag, item) runs verifyArchive's checks and adds rebuilt: it makes a reproducible export of item (an Item or an ArchiveSeedRef) at the bag's Seed-Bag-As-Of and compares the two, listing in differingFiles the paths that differ or exist in only one bag. It takes no outside hash, so anchor is not_checked. rebuilt is not_checked, with the reason in detail, for an ordinary bag, a bag of another seed, another Seed-Bag-Format, or an export that refuses.

import { verifyArchiveAgainstChain } from '@seedprotocol/archive'

const { fixity, rebuilt, differingFiles } = await verifyArchiveAgainstChain(bag, {
  seedUid: '0xa313944ea62a86bfbb9db2d9be984d7ad8641eead88d5769500de5f6c3bdb3eb',
  modelName: 'Post',
})

Check a published bag

An archive anchor is an attestation on a Version that records a reproducible bag's asOf, its path manifest's Arweave transaction and its tagmanifest hash. Archive anchors covers writing one with anchorArchive. The functions here read anchors from @seedprotocol/archive/verify, which pulls in no Html parser or Seed code, so a reader's page can load it on demand:

import type { SeedRecord } from '@seedprotocol/query'

async function checkArchive(record: SeedRecord) {
  const { verifyPublishedArchive } = await import('@seedprotocol/archive/verify')
  return verifyPublishedArchive({
    versionUid: record.versionUid,
    easEndpoint: 'https://optimism-sepolia.easscan.org/graphql',
    gateways: ['https://arweave.net'],
  })
}

A reader counts an anchor only from the Version's attester, because anyone can attest with any refUID. These functions ignore anchors from anyone else.

verifyPublishedArchive

From a Version UID alone, it finds the Version's newest live anchor, reads the bag its path manifest lays out from a gateway, and checks it. Each check is reported on its own, with no overall verdict.

OptionTypeDefaultDescription
versionUidstringrequiredThe Version to check
easEndpointstringrequiredThe EAS GraphQL endpoint of the Version's chain
gatewaysreadonly string[]['https://arweave.net']Gateway origins, tried in order for each file at <gateway>/raw/<tx>
asOfnumberthe newest anchorCheck the anchor whose coveredUntil equals this time, in unix seconds. It must match exactly; any other value gives no_anchor
maxBytesnumbernoneRead the payload only when Payload-Oxum says it's at most this many bytes. Over it, anchor and identity are still checked from the tag files, and fixity and record are not_checked
onProgress({ loadedBytes, totalBytes }) => voidnoneCalled after each payload file; totalBytes is from Payload-Oxum
fetchtypeof fetchglobal fetchFor EAS and the gateways
signalAbortSignalnoneStops the reads; the call then throws the abort reason
CheckPasses when
attestationThe Version and its seed have the same attester and that attester has a live anchor on the Version. no_anchor when the Version is on EAS with no such anchor (or none at asOf); not_checked when EAS hasn't reported the Version or couldn't be read; failed when the Version isn't from its seed's attester
fixityThe fetched files match the bag's own manifests
anchorThe bag's tagmanifest hash matches the anchor's
identitybag-info.txt names this Version and Seed-Bag-As-Of equals the anchor's coveredUntil
recordEvery attestation data/item.json names is on this Version from its attester. Values aren't compared, which would need the schema
revocationThe Version and its seed are live on EAS. A revoked one fails here while the bag still checks out
timestampNever: always not_checked, since anchors carry no timestamp proof yet

The result also has archiveAnchor (the anchor checked: uid, attester, timeCreated, coveredUntil, manifestTx, tagmanifestSha256), size ({ payloadBytes, payloadFiles } from Payload-Oxum) and recorded (the seed, Version, and per-property attestation and transaction from data/item.json, which is what a passed record vouches for).

It doesn't throw for what it finds: a service that can't be reached leaves its checks not_checked with the reason in detail, so an outage never reads as a forgery. It throws only when signal aborts.

When the anchor is found but the gateway can't serve its path manifest, fixity says Couldn't read manifest <tx> and anchor says "There is no anchored hash to compare with", though the anchor's hash was read. Read attestation and fixity to tell a gateway failure from a missing anchor.

findArchiveAnchors

function findArchiveAnchors(options: {
  versionUid: string
  easEndpoint: string
  fetch?: typeof fetch
  signal?: AbortSignal
}): Promise<ArchiveAnchor[]>

The live anchors on a Version from its attester, newest first, without downloading a bag, for deciding whether to offer the check. Empty when the indexer hasn't reported the Version. Unlike verifyPublishedArchive, it throws when EAS can't be read.

decodeArchiveAnchorData(data) decodes an anchor attestation's data (three ABI words) to { coveredUntil, manifestTx, tagmanifestSha256 }, or returns undefined for anything else. ARCHIVE_ANCHOR_SCHEMA_UID (0xf7f6e2f226f29542fa8bd1763d042d04f47a0a9c3647753f68cc113e26f1739e) and VERSION_SCHEMA_UID are the two schema UIDs it reads; the anchor schema is uint64 covered_until,bytes32 manifest_tx,bytes32 tagmanifest_sha256 and its UID is the same on every chain. @seedprotocol/eas has the same constants, a raw anchor read and the ID converters for code that already uses it.

A bag on Arweave

A path manifest (arweave/paths 0.2.0) lays a bag out on Arweave: each file is its own transaction, and the path manifest maps bag paths to them, so a gateway serves the whole bag under one ID. anchorArchive uploads one for you; these functions are what it and verifyPublishedArchive use.

import { buildPathManifest, parsePathManifest, readBagFromManifest, verifyArchive } from '@seedprotocol/archive'

// Writing: transactions for the bag's other files come from your own uploads
const { bytes } = buildPathManifest(bag, { ...arweaveTransactions, ...uploaded })

// Reading, from the path manifest's bytes alone
const fetchTransaction = async (id: string) => {
  const response = await fetch(`https://arweave.net/raw/${id}`)
  return response.ok ? new Uint8Array(await response.arrayBuffer()) : undefined
}
const copy = await readBagFromManifest(parsePathManifest(manifestBytes), fetchTransaction)
const { fixity, anchor } = verifyArchive(copy, { expectedTagmanifestSha256: anchorHash })

buildPathManifest(bag, transactions) returns { manifest, bytes }. transactions maps every bag path to its transaction ID; a missing one throws ArchiveError invalid_manifest (VERIFY.txt has no transaction). data/index.html is the path manifest's index. A gateway serves the index at the path manifest's root, so each data/assets/ file is also listed as assets/<file> for the page's relative links. A bag with several Html properties has no index. bytes depend only on the input; upload them with the content type PATH_MANIFEST_CONTENT_TYPE (application/x.arweave-manifest+json).

parsePathManifest(data) takes a Uint8Array or string and checks every path and transaction ID, since the path manifest comes from Arweave. It throws ArchiveError: invalid_manifest for anything that isn't arweave/paths 0.2.0, isn't JSON or has a malformed ID, and invalid_path for a path such as ../x.

readBagFromManifest(manifest, fetchTransaction, { only? }) fetches each path and returns the bag without the assets/ aliases. fetchTransaction resolves to the transaction's bytes, or undefined, which throws missing_file. only limits it to the paths it accepts, such as the tag files ((path) => !path.startsWith('data/')).

The path manifest says where the files are and doesn't vouch for them. Only verifyArchive against an outside hash does.

Build bags directly

The building blocks under exportArchive work on any content.

import { createBag, validateBag } from '@seedprotocol/archive'
import { readBagDirectory, writeBagDirectory } from '@seedprotocol/archive/node'

const bag = createBag({
  payload: [{ path: 'index.html', bytes: new TextEncoder().encode(html) }],
  info: [['Source-Organization', 'The Daily Example']],
})
await writeBagDirectory(bag, './bag')
const { valid, errors } = validateBag(await readBagDirectory('./bag'))

createBag({ payload, tagFiles?, info?, baggingDate?, softwareAgent? }) builds a BagIt 1.0 bag: bagit.txt, bag-info.txt, SHA-256 and SHA-512 payload and tag manifests. payload paths are relative to data/; tagFiles paths (such as metadata/dc.xml) are relative to the bag's base and go in the tag manifests. It adds Bagging-Date (today, UTC), Payload-Oxum and Bag-Software-Agent (DEFAULT_SOFTWARE_AGENT) unless info has them.

It throws ArchiveError for a path that won't survive another file system: empty, . or .. segments, a backslash, control characters, %, text not in Unicode NFC, or two paths that differ only in letter case. % is refused because bagit-python 1.9.0 doesn't decode the %25 RFC 8493 writes in manifests. assertBagPath(path) runs the same check. ArchiveError's code is 'invalid_path', 'duplicate_path', 'invalid_bag_info', 'invalid_manifest' or 'missing_file'.

validateBag(bag) returns { valid, errors }, one error per problem naming the file. It checks bagit.txt, every payload and tag manifest entry, unlisted payload files and Payload-Oxum, and reports a manifest for an algorithm other than SHA-256 and SHA-512 as an error. For an independent check, run the Library of Congress validator on the directory:

pipx run --spec bagit==1.9.0 bagit.py --validate ./bag

writeBagDirectory(bag, directory) throws a plain Error (… is not empty) for a directory that has files, so a bag never mixes with files its manifests don't list. readBagDirectory(directory) reads every file under it as a bag.

Also exported: getBagFile(bag, path), encodeManifestPath and decodeManifestPath (RFC 8493 percent-encoding), digestHex(algorithm, bytes), BAG_ALGORITHMS (['sha256', 'sha512']) and BAGIT_VERSION ('1.0').

Archive documents and metadata

parseArchiveDocument(html) reads the body and descriptive fields of Html marked up as an h-entry. The body comes from .h-entry .e-content, else the first [itemprop=articleBody], else a lone top-level <article> without its direct <header> and <footer>, else the whole input; method says which ('h-entry', 'articleBody', 'article', 'fragment'). It returns content (sliced from the input, not re-serialized), name, summary, published, authors, featured and url. Implied properties and value-class patterns aren't applied.

rewriteHtmlUrls(html, replace) replaces whole src, href and poster values for which the function replace returns a new URL, splicing them into the original string so every other byte stays; srcset isn't rewritten. findHtmlUrls(html) lists those values once each in document order, and rewriteHtmlAttributes(html, rewrite) is the general form for any attribute.

dublinCoreXml(record) writes an oai_dc:dc record from the 15 DCMI elements, each a string or array. premisXml({ objects, events, agents }) writes PREMIS 3.0 with event and agent types from the Library of Congress preservation vocabularies, listed in PREMIS_EVENT_TYPES and PREMIS_AGENT_TYPES.

On this page