diff --git a/README.md b/README.md
index d338cf6..10ff60e 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,6 @@
# deepevents.ai
deepevents.ai main codebase
+
+## Added modules
+
+- `repository-api-export-contract/` validates scientific repository manifests, public REST API coverage, export bundle contents, and Git-compatible CLI workflows for SCIBASE project repositories.
diff --git a/repository-api-export-contract/README.md b/repository-api-export-contract/README.md
new file mode 100644
index 0000000..0fce01a
--- /dev/null
+++ b/repository-api-export-contract/README.md
@@ -0,0 +1,27 @@
+# Repository API Export Contract
+
+This module covers the programmatic access and export-bundle portion of issue #10, "Project Repository & Version Control".
+
+It builds a deterministic contract for a scientific project repository that includes:
+
+- typed repository component manifests for manuscript, data, code, notebooks, results, protocols, and metadata
+- content hashes and a repository integrity root for reproducibility checks
+- REST API route coverage for GET, POST, and PUT workflows plus export access
+- export-bundle entries with manifest, metadata, citation, reproducibility, and component files
+- a Git-compatible CLI transcript for advanced lab users
+- readiness checks that fail closed when required component, API, or reproducibility evidence is missing
+
+## Run
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+The demo prints the export readiness decision, bundle hash, API coverage, and CLI workflow for the sample project in `data/sample-project.json`.
+The video demo renders `docs/demo.mp4` with a four-step walkthrough of the manifest, API coverage, export bundle, and CLI workflow.
+
+## Requirement Map
+
+See [docs/requirement-map.md](docs/requirement-map.md) for the issue requirement mapping.
diff --git a/repository-api-export-contract/data/sample-project.json b/repository-api-export-contract/data/sample-project.json
new file mode 100644
index 0000000..988b27d
--- /dev/null
+++ b/repository-api-export-contract/data/sample-project.json
@@ -0,0 +1,135 @@
+{
+ "repositoryId": "neuro-lab/sleep-spindle-atlas",
+ "title": "Sleep Spindle Atlas With Reproducible Notebook Outputs",
+ "semanticVersion": "1.2.0",
+ "tag": "preprint-v1.2",
+ "doi": "10.5555/scibase.sleep-spindle-atlas.v1.2",
+ "citation": "Rivera, M.; Chen, I.; Okafor, T. (2026). Sleep Spindle Atlas With Reproducible Notebook Outputs. SCIBASE.AI.",
+ "authors": [
+ {
+ "name": "Mira Rivera",
+ "orcid": "0000-0002-1000-0001",
+ "affiliation": "North Coast Neuroscience Lab"
+ },
+ {
+ "name": "Isaac Chen",
+ "orcid": "0000-0002-1000-0002",
+ "affiliation": "North Coast Neuroscience Lab"
+ }
+ ],
+ "funding": [
+ {
+ "funder": "Open Sleep Foundation",
+ "grant": "OSF-2026-17"
+ }
+ ],
+ "metadata": {
+ "schemaOrgType": "Dataset",
+ "license": "CC-BY-4.0",
+ "keywords": ["sleep", "eeg", "spindles", "reproducibility"]
+ },
+ "reproducibility": {
+ "pipeline": "notebooks/run_analysis.ipynb",
+ "environment": "code/environment.yml",
+ "status": "passing",
+ "evidence": [
+ "results/spindle-summary.csv",
+ "results/figures/spindle-density.png"
+ ]
+ },
+ "components": [
+ {
+ "path": "manuscript/main.md",
+ "mediaType": "text/markdown",
+ "content": "# Sleep Spindle Atlas\n\nWe report a reproducible spindle atlas across 120 EEG sessions.",
+ "reproducibilityRole": "narrative"
+ },
+ {
+ "path": "data/eeg-session-index.csv",
+ "mediaType": "text/csv",
+ "content": "session_id,subject_id,minutes\ns001,p001,45\ns002,p002,42\n",
+ "reproducibilityRole": "input-data"
+ },
+ {
+ "path": "code/spindle_detector.py",
+ "mediaType": "text/x-python",
+ "content": "def detect_spindles(signal):\n return [window for window in signal if window.get('sigma_power', 0) > 0.72]\n",
+ "reproducibilityRole": "analysis-code"
+ },
+ {
+ "path": "code/environment.yml",
+ "mediaType": "application/x-yaml",
+ "content": "name: spindle-atlas\nchannels: [conda-forge]\ndependencies: [python=3.12, numpy, pandas]\n",
+ "reproducibilityRole": "environment"
+ },
+ {
+ "path": "notebooks/run_analysis.ipynb",
+ "mediaType": "application/x-ipynb+json",
+ "content": {
+ "cells": [
+ {
+ "cell_type": "code",
+ "source": "from code.spindle_detector import detect_spindles"
+ }
+ ]
+ },
+ "reproducibilityRole": "notebook"
+ },
+ {
+ "path": "results/spindle-summary.csv",
+ "mediaType": "text/csv",
+ "content": "group,mean_density\ncontrol,2.4\npatient,1.9\n",
+ "reproducibilityRole": "reported-output"
+ },
+ {
+ "path": "results/figures/spindle-density.png",
+ "mediaType": "image/png",
+ "summary": "PNG chart of spindle density by group",
+ "sizeBytes": 614400,
+ "reproducibilityRole": "reported-output"
+ },
+ {
+ "path": "protocols/eeg-acquisition.md",
+ "mediaType": "text/markdown",
+ "content": "# EEG Acquisition Protocol\n\nRecord overnight EEG with a 256 Hz sampling rate.",
+ "reproducibilityRole": "protocol"
+ },
+ {
+ "path": "metadata.json",
+ "mediaType": "application/json",
+ "content": {
+ "@type": "Dataset",
+ "name": "Sleep Spindle Atlas With Reproducible Notebook Outputs",
+ "license": "CC-BY-4.0"
+ },
+ "reproducibilityRole": "metadata"
+ }
+ ],
+ "apiRoutes": [
+ {
+ "method": "GET",
+ "path": "/api/projects/:repositoryId",
+ "scope": "project.read",
+ "public": true
+ },
+ {
+ "method": "POST",
+ "path": "/api/projects",
+ "scope": "project.write",
+ "public": true
+ },
+ {
+ "method": "PUT",
+ "path": "/api/projects/:repositoryId",
+ "scope": "project.update",
+ "public": true
+ },
+ {
+ "method": "GET",
+ "path": "/api/projects/:repositoryId/export",
+ "scope": "export.read",
+ "public": true,
+ "includesIntegrityRoot": true
+ }
+ ]
+}
diff --git a/repository-api-export-contract/docs/demo.mp4 b/repository-api-export-contract/docs/demo.mp4
new file mode 100644
index 0000000..3b4111c
Binary files /dev/null and b/repository-api-export-contract/docs/demo.mp4 differ
diff --git a/repository-api-export-contract/docs/demo.svg b/repository-api-export-contract/docs/demo.svg
new file mode 100644
index 0000000..7f80819
--- /dev/null
+++ b/repository-api-export-contract/docs/demo.svg
@@ -0,0 +1,27 @@
+
diff --git a/repository-api-export-contract/docs/requirement-map.md b/repository-api-export-contract/docs/requirement-map.md
new file mode 100644
index 0000000..12c4f2f
--- /dev/null
+++ b/repository-api-export-contract/docs/requirement-map.md
@@ -0,0 +1,15 @@
+# Requirement Map
+
+Issue #10 asks for a scientific project repository system with version control, programmatic access, and export support. This module focuses on the API/export slice so it does not duplicate broad repository ledger, dataset diff, release embargo, schema migration, notebook replay, or citation impact submissions already open.
+
+| Issue requirement | Module coverage |
+| --- | --- |
+| Repository structure with manuscript, data, code, notebooks, results, protocols, and metadata | `buildRepositoryManifest` validates required component coverage and records typed component entries. |
+| Hash-based integrity for reproducibility | Every component receives a deterministic `sha256:` content hash, and the manifest receives an integrity root. |
+| Semantic versioning and tagged releases | The manifest records `semanticVersion` and `tag`, and the CLI transcript clones/export by tag. |
+| Computation-aware reproducibility | The readiness gate checks reproducibility status and evidence paths before release. |
+| DOI and citation generation | Export bundles include `citation/cite-this-project.json` with DOI, citation text, and tag metadata. |
+| Public REST API for project and data access | `validateRestApiPlan` requires public GET, POST, and PUT routes with scopes. |
+| Export bundles | `planExportBundle` emits manifest, API contract, reproducibility runbook, citation metadata, and component entries. |
+| Git-compatible CLI for advanced contributors | `buildGitCompatibleCliPlan` emits clone, status, export, and route-discovery commands. |
+| Reviewable local validation | `npm test` covers ready, missing metadata, incomplete API, and failing reproducibility cases. |
diff --git a/repository-api-export-contract/package.json b/repository-api-export-contract/package.json
new file mode 100644
index 0000000..b9d70c0
--- /dev/null
+++ b/repository-api-export-contract/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "repository-api-export-contract",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node test/repository-api-export-contract.test.js",
+ "demo": "node scripts/demo.js",
+ "demo:video": "clang -fobjc-arc -framework Foundation -framework AppKit -framework AVFoundation -framework CoreMedia -framework CoreVideo scripts/render-demo-video.m -o /tmp/scibase-repository-api-export-demo && /tmp/scibase-repository-api-export-demo docs/demo.mp4"
+ }
+}
diff --git a/repository-api-export-contract/scripts/demo.js b/repository-api-export-contract/scripts/demo.js
new file mode 100644
index 0000000..59a497e
--- /dev/null
+++ b/repository-api-export-contract/scripts/demo.js
@@ -0,0 +1,23 @@
+import { readFileSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+import { dirname, join } from "node:path"
+import { createRepositoryApiExportContract } from "../src/repository-api-export-contract.js"
+
+const here = dirname(fileURLToPath(import.meta.url))
+const samplePath = join(here, "../data/sample-project.json")
+const project = JSON.parse(readFileSync(samplePath, "utf8"))
+const contract = createRepositoryApiExportContract(project)
+
+console.log("Repository API export contract demo")
+console.log("Repository:", contract.manifest.repositoryId)
+console.log("Tag:", contract.manifest.tag)
+console.log("Ready:", contract.readiness.ready)
+console.log("Integrity root:", contract.manifest.integrityRoot)
+console.log("Bundle hash:", contract.exportBundle.bundleHash)
+console.log("API methods:", contract.apiPlan.methods.join(", "))
+console.log("Export route:", contract.apiPlan.exportRoute)
+console.log("Bundle entries:", contract.exportBundle.entryCount)
+console.log("CLI workflow:")
+for (const step of contract.cliPlan) {
+ console.log(`- ${step.command}`)
+}
diff --git a/repository-api-export-contract/scripts/render-demo-video.m b/repository-api-export-contract/scripts/render-demo-video.m
new file mode 100644
index 0000000..5224800
--- /dev/null
+++ b/repository-api-export-contract/scripts/render-demo-video.m
@@ -0,0 +1,131 @@
+#import
+#import
+
+static NSDictionary *textAttrs(CGFloat size, NSColor *color, BOOL bold) {
+ NSFont *font = bold ? [NSFont boldSystemFontOfSize:size] : [NSFont systemFontOfSize:size];
+ return @{NSFontAttributeName: font, NSForegroundColorAttributeName: color};
+}
+
+static void fillRound(NSRect rect, CGFloat radius, NSColor *fill, NSColor *stroke) {
+ NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius];
+ [fill setFill];
+ [path fill];
+ if (stroke) {
+ [stroke setStroke];
+ [path setLineWidth:2.0];
+ [path stroke];
+ }
+}
+
+static void drawText(NSString *text, CGFloat x, CGFloat y, CGFloat width, CGFloat size, NSColor *color, BOOL bold) {
+ [text drawInRect:NSMakeRect(x, y, width, size + 12.0) withAttributes:textAttrs(size, color, bold)];
+}
+
+static void drawFrame(CGContextRef context, int frame, int totalFrames) {
+ CGFloat t = (CGFloat)frame / (CGFloat)(totalFrames - 1);
+ int stage = MIN(3, (int)floor(t * 4.0));
+
+ NSGraphicsContext *graphicsContext = [NSGraphicsContext graphicsContextWithCGContext:context flipped:NO];
+ [NSGraphicsContext saveGraphicsState];
+ [NSGraphicsContext setCurrentContext:graphicsContext];
+
+ [[NSColor colorWithCalibratedRed:0.06 green:0.09 blue:0.16 alpha:1.0] setFill];
+ NSRectFill(NSMakeRect(0, 0, 1280, 720));
+ fillRound(NSMakeRect(64, 56, 1152, 608), 12, [NSColor colorWithCalibratedWhite:0.98 alpha:1.0], nil);
+
+ drawText(@"Repository API Export Contract", 104, 594, 900, 34, [NSColor colorWithCalibratedWhite:0.07 alpha:1.0], YES);
+ drawText(@"Programmatic access and export-bundle readiness for SCIBASE project repositories", 104, 558, 960, 18, [NSColor colorWithCalibratedRed:0.28 green:0.33 blue:0.41 alpha:1.0], NO);
+
+ NSArray *cards = @[
+ @{@"title": @"1. Manifest", @"body": @"Required repository components and deterministic content hashes", @"detail": @"manuscript, data, code, notebooks, results, protocols, metadata", @"rect": [NSValue valueWithRect:NSMakeRect(104, 366, 500, 150)], @"fill": @[@0.88, @0.96, @0.99], @"stroke": @[@0.01, @0.52, @0.78]},
+ @{@"title": @"2. REST API", @"body": @"Public GET, POST, and PUT routes with scoped access", @"detail": @"export route carries the manifest integrity root", @"rect": [NSValue valueWithRect:NSMakeRect(676, 366, 500, 150)], @"fill": @[@0.86, @0.99, @0.91], @"stroke": @[@0.09, @0.64, @0.29]},
+ @{@"title": @"3. Export Bundle", @"body": @"Manifest, API contract, runbook, citation metadata, and files", @"detail": @"ordered entries sign a reproducible bundle hash", @"rect": [NSValue valueWithRect:NSMakeRect(104, 160, 500, 150)], @"fill": @[@1.0, @0.95, @0.78], @"stroke": @[@0.85, @0.47, @0.02]},
+ @{@"title": @"4. CLI Workflow", @"body": @"clone, status, export, and route discovery commands", @"detail": @"Git-compatible automation for lab users", @"rect": [NSValue valueWithRect:NSMakeRect(676, 160, 500, 150)], @"fill": @[@0.95, @0.91, @1.0], @"stroke": @[@0.58, @0.20, @0.92]},
+ ];
+
+ for (NSUInteger i = 0; i < [cards count]; i++) {
+ NSDictionary *card = cards[i];
+ NSRect rect = [card[@"rect"] rectValue];
+ NSArray *fill = card[@"fill"];
+ NSArray *stroke = card[@"stroke"];
+ NSColor *fillColor = [NSColor colorWithCalibratedRed:[fill[0] doubleValue] green:[fill[1] doubleValue] blue:[fill[2] doubleValue] alpha:1.0];
+ NSColor *strokeColor = [NSColor colorWithCalibratedRed:[stroke[0] doubleValue] green:[stroke[1] doubleValue] blue:[stroke[2] doubleValue] alpha:1.0];
+ fillRound(rect, 10, fillColor, strokeColor);
+ if ((int)i == stage) {
+ NSBezierPath *highlight = [NSBezierPath bezierPathWithRoundedRect:NSInsetRect(rect, -8, -8) xRadius:14 yRadius:14];
+ [[NSColor colorWithCalibratedRed:0.10 green:0.45 blue:0.90 alpha:0.28] setFill];
+ [highlight fill];
+ }
+ drawText(card[@"title"], rect.origin.x + 28, rect.origin.y + 108, 420, 24, strokeColor, YES);
+ drawText(card[@"body"], rect.origin.x + 28, rect.origin.y + 70, 430, 17, [NSColor colorWithCalibratedWhite:0.08 alpha:1.0], NO);
+ drawText(card[@"detail"], rect.origin.x + 28, rect.origin.y + 38, 430, 15, [NSColor colorWithCalibratedRed:0.30 green:0.35 blue:0.43 alpha:1.0], NO);
+ }
+
+ NSString *footer = [NSString stringWithFormat:@"ready=true entries=13 api=GET,POST,PUT bundle=sha256:cfa0be77...818c frame=%d/%d", frame + 1, totalFrames];
+ drawText(footer, 104, 90, 1000, 18, [NSColor colorWithCalibratedRed:0.20 green:0.25 blue:0.33 alpha:1.0], NO);
+
+ [NSGraphicsContext restoreGraphicsState];
+}
+
+int main(int argc, const char *argv[]) {
+ @autoreleasepool {
+ NSString *output = argc > 1 ? [NSString stringWithUTF8String:argv[1]] : @"docs/demo.mp4";
+ NSURL *outputURL = [NSURL fileURLWithPath:output];
+ [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil];
+
+ NSError *error = nil;
+ AVAssetWriter *writer = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeMPEG4 error:&error];
+ if (!writer) {
+ NSLog(@"failed to create writer: %@", error);
+ return 1;
+ }
+
+ NSDictionary *settings = @{
+ AVVideoCodecKey: AVVideoCodecTypeH264,
+ AVVideoWidthKey: @1280,
+ AVVideoHeightKey: @720,
+ AVVideoCompressionPropertiesKey: @{AVVideoAverageBitRateKey: @2500000}
+ };
+ AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:settings];
+ input.expectsMediaDataInRealTime = NO;
+ NSDictionary *attributes = @{
+ (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32ARGB),
+ (NSString *)kCVPixelBufferWidthKey: @1280,
+ (NSString *)kCVPixelBufferHeightKey: @720,
+ };
+ AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input sourcePixelBufferAttributes:attributes];
+ [writer addInput:input];
+ [writer startWriting];
+ [writer startSessionAtSourceTime:kCMTimeZero];
+
+ const int fps = 24;
+ const int totalFrames = 96;
+ for (int frame = 0; frame < totalFrames; frame++) {
+ while (!input.readyForMoreMediaData) {
+ [NSThread sleepForTimeInterval:0.01];
+ }
+ CVPixelBufferRef pixelBuffer = NULL;
+ CVPixelBufferPoolCreatePixelBuffer(NULL, adaptor.pixelBufferPool, &pixelBuffer);
+ CVPixelBufferLockBaseAddress(pixelBuffer, 0);
+ CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
+ CGContextRef context = CGBitmapContextCreate(CVPixelBufferGetBaseAddress(pixelBuffer), 1280, 720, 8, CVPixelBufferGetBytesPerRow(pixelBuffer), colorSpace, kCGImageAlphaPremultipliedFirst);
+ drawFrame(context, frame, totalFrames);
+ CGContextRelease(context);
+ CGColorSpaceRelease(colorSpace);
+ CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
+ [adaptor appendPixelBuffer:pixelBuffer withPresentationTime:CMTimeMake(frame, fps)];
+ CVPixelBufferRelease(pixelBuffer);
+ }
+
+ [input markAsFinished];
+ [writer finishWritingWithCompletionHandler:^{}];
+ while (writer.status == AVAssetWriterStatusWriting) {
+ [NSThread sleepForTimeInterval:0.05];
+ }
+ if (writer.status != AVAssetWriterStatusCompleted) {
+ NSLog(@"writer failed: %@", writer.error);
+ return 1;
+ }
+ }
+ return 0;
+}
diff --git a/repository-api-export-contract/src/repository-api-export-contract.js b/repository-api-export-contract/src/repository-api-export-contract.js
new file mode 100644
index 0000000..415fbcd
--- /dev/null
+++ b/repository-api-export-contract/src/repository-api-export-contract.js
@@ -0,0 +1,288 @@
+import { createHash } from "node:crypto"
+
+const REQUIRED_COMPONENT_DIRS = [
+ "manuscript",
+ "data",
+ "code",
+ "notebooks",
+ "results",
+ "protocols",
+]
+
+const REQUIRED_API_METHODS = new Set(["GET", "POST", "PUT"])
+
+const normalizePath = (path) => {
+ if (typeof path !== "string" || path.trim() === "") {
+ throw new Error("component path must be a non-empty string")
+ }
+ const normalized = path.replaceAll("\\", "/").replace(/^\/+/, "")
+ if (normalized.includes("..")) {
+ throw new Error(`component path cannot traverse directories: ${path}`)
+ }
+ return normalized
+}
+
+const stableStringify = (value) => {
+ if (value === null || typeof value !== "object") return JSON.stringify(value)
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
+ .join(",")}}`
+}
+
+const hashValue = (value) =>
+ `sha256:${createHash("sha256").update(stableStringify(value)).digest("hex")}`
+
+const classifyComponentDir = (path) => {
+ if (path === "metadata.json") return "metadata"
+ const topLevel = path.split("/")[0]
+ return REQUIRED_COMPONENT_DIRS.includes(topLevel) ? topLevel : "extra"
+}
+
+const normalizeComponent = (component) => {
+ const path = normalizePath(component.path)
+ const content =
+ component.content ??
+ component.summary ??
+ component.externalRef ??
+ component.hash ??
+ `${path}:${component.mediaType ?? "application/octet-stream"}`
+ const sizeBytes =
+ component.sizeBytes ??
+ (typeof content === "string"
+ ? Buffer.byteLength(content)
+ : Buffer.byteLength(stableStringify(content)))
+
+ return {
+ path,
+ componentType: classifyComponentDir(path),
+ mediaType: component.mediaType ?? "application/octet-stream",
+ visibility: component.visibility ?? "public",
+ contentHash: component.hash ?? hashValue(content),
+ sizeBytes,
+ lfsPointer: Boolean(component.lfsPointer || sizeBytes > 5_000_000),
+ reproducibilityRole: component.reproducibilityRole ?? "supporting",
+ }
+}
+
+const coverageForComponents = (components) => {
+ const coverage = Object.fromEntries(
+ [...REQUIRED_COMPONENT_DIRS, "metadata"].map((name) => [name, false]),
+ )
+
+ for (const component of components) {
+ if (component.componentType in coverage) coverage[component.componentType] = true
+ }
+
+ return coverage
+}
+
+const missingCoverage = (coverage) =>
+ Object.entries(coverage)
+ .filter(([, present]) => !present)
+ .map(([name]) => name)
+
+export const buildRepositoryManifest = (project) => {
+ const components = (project.components ?? []).map(normalizeComponent)
+ const coverage = coverageForComponents(components)
+ const missing = missingCoverage(coverage)
+ const semanticVersion = project.semanticVersion ?? project.version ?? "0.1.0"
+
+ const manifest = {
+ repositoryId: project.repositoryId,
+ title: project.title,
+ semanticVersion,
+ tag: project.tag ?? `v${semanticVersion}`,
+ doi: project.doi,
+ citation: project.citation,
+ authors: project.authors ?? [],
+ funding: project.funding ?? [],
+ generatedAt: project.generatedAt ?? "2026-05-16T00:00:00.000Z",
+ components,
+ requiredCoverage: coverage,
+ reproducibility: {
+ pipeline: project.reproducibility?.pipeline ?? "not-declared",
+ environment: project.reproducibility?.environment ?? "not-declared",
+ status: project.reproducibility?.status ?? "unknown",
+ evidence:
+ project.reproducibility?.evidence?.map((item) => normalizePath(item)) ?? [],
+ },
+ metadata: {
+ schemaOrgType: project.metadata?.schemaOrgType ?? "ScholarlyArticle",
+ license: project.metadata?.license ?? "not-declared",
+ keywords: project.metadata?.keywords ?? [],
+ },
+ }
+
+ manifest.integrityRoot = hashValue({
+ repositoryId: manifest.repositoryId,
+ semanticVersion: manifest.semanticVersion,
+ components: manifest.components.map(({ path, contentHash }) => ({
+ path,
+ contentHash,
+ })),
+ reproducibility: manifest.reproducibility,
+ })
+
+ manifest.blockers = missing.map(
+ (name) => `missing required repository component: ${name}`,
+ )
+
+ return manifest
+}
+
+export const validateRestApiPlan = (routes, manifest) => {
+ const normalizedRoutes = (routes ?? []).map((route) => ({
+ method: String(route.method ?? "").toUpperCase(),
+ path: route.path,
+ scope: route.scope,
+ public: Boolean(route.public),
+ response: route.response ?? "json",
+ includesIntegrityRoot: Boolean(route.includesIntegrityRoot),
+ }))
+
+ const methods = new Set(normalizedRoutes.map((route) => route.method))
+ const missingMethods = [...REQUIRED_API_METHODS].filter(
+ (method) => !methods.has(method),
+ )
+ const exportRoute = normalizedRoutes.find(
+ (route) =>
+ route.method === "GET" &&
+ route.path?.includes("/export") &&
+ route.includesIntegrityRoot,
+ )
+ const unsafeRoutes = normalizedRoutes.filter((route) => !route.public)
+ const missingScopes = normalizedRoutes.filter((route) => !route.scope)
+
+ const blockers = [
+ ...missingMethods.map((method) => `missing public REST ${method} route`),
+ ...(exportRoute ? [] : ["missing GET export route with integrity root"]),
+ ...unsafeRoutes.map((route) => `route is not public: ${route.method} ${route.path}`),
+ ...missingScopes.map((route) => `route lacks scope: ${route.method} ${route.path}`),
+ ]
+
+ return {
+ ready: blockers.length === 0,
+ manifestIntegrityRoot: manifest.integrityRoot,
+ methods: [...methods].sort(),
+ exportRoute: exportRoute?.path ?? null,
+ routes: normalizedRoutes,
+ blockers,
+ }
+}
+
+export const planExportBundle = (manifest, apiPlan) => {
+ const manifestEntry = {
+ path: "manifest.json",
+ type: "manifest",
+ hash: hashValue(manifest),
+ }
+ const apiEntry = {
+ path: "api/routes.json",
+ type: "api-contract",
+ hash: hashValue(apiPlan.routes),
+ }
+ const reproducibilityEntry = {
+ path: "reproducibility/runbook.json",
+ type: "reproducibility",
+ hash: hashValue(manifest.reproducibility),
+ }
+ const citationEntry = {
+ path: "citation/cite-this-project.json",
+ type: "citation",
+ hash: hashValue({
+ doi: manifest.doi,
+ citation: manifest.citation,
+ tag: manifest.tag,
+ }),
+ }
+ const componentEntries = manifest.components.map((component) => ({
+ path: component.path,
+ type: component.componentType,
+ hash: component.contentHash,
+ mediaType: component.mediaType,
+ lfsPointer: component.lfsPointer,
+ }))
+
+ const entries = [
+ manifestEntry,
+ apiEntry,
+ reproducibilityEntry,
+ citationEntry,
+ ...componentEntries,
+ ].sort((a, b) => a.path.localeCompare(b.path))
+
+ return {
+ format: "scibase-export-bundle-v1",
+ repositoryId: manifest.repositoryId,
+ tag: manifest.tag,
+ entryCount: entries.length,
+ entries,
+ bundleHash: hashValue(entries.map(({ path, hash }) => ({ path, hash }))),
+ }
+}
+
+export const buildGitCompatibleCliPlan = (manifest, exportBundle) => {
+ const repoRef = `${manifest.repositoryId}@${manifest.tag}`
+ return [
+ {
+ command: `scibase repo clone ${repoRef}`,
+ purpose: "clone a tagged scientific repository snapshot",
+ },
+ {
+ command: `scibase repo status --integrity ${manifest.integrityRoot}`,
+ purpose: "verify local component hashes before editing or reproducing",
+ },
+ {
+ command: `scibase repo export ${repoRef} --format zip --bundle-hash ${exportBundle.bundleHash}`,
+ purpose: "create an archival export bundle with manifest and citation metadata",
+ },
+ {
+ command: `scibase repo api-routes ${manifest.repositoryId}`,
+ purpose: "discover public REST routes for GET/POST/PUT access",
+ },
+ ]
+}
+
+export const assessExportReadiness = (manifest, apiPlan, exportBundle) => {
+ const blockers = [
+ ...manifest.blockers,
+ ...apiPlan.blockers,
+ ...(manifest.reproducibility.status === "passing"
+ ? []
+ : [`reproducibility status is ${manifest.reproducibility.status}`]),
+ ...(exportBundle.entryCount >= manifest.components.length + 4
+ ? []
+ : ["export bundle is missing contract entries"]),
+ ]
+
+ return {
+ ready: blockers.length === 0,
+ blockers,
+ releaseSummary: {
+ repositoryId: manifest.repositoryId,
+ tag: manifest.tag,
+ doi: manifest.doi,
+ integrityRoot: manifest.integrityRoot,
+ bundleHash: exportBundle.bundleHash,
+ apiMethods: apiPlan.methods,
+ },
+ }
+}
+
+export const createRepositoryApiExportContract = (project) => {
+ const manifest = buildRepositoryManifest(project)
+ const apiPlan = validateRestApiPlan(project.apiRoutes, manifest)
+ const exportBundle = planExportBundle(manifest, apiPlan)
+ const cliPlan = buildGitCompatibleCliPlan(manifest, exportBundle)
+ const readiness = assessExportReadiness(manifest, apiPlan, exportBundle)
+
+ return {
+ manifest,
+ apiPlan,
+ exportBundle,
+ cliPlan,
+ readiness,
+ }
+}
diff --git a/repository-api-export-contract/test/repository-api-export-contract.test.js b/repository-api-export-contract/test/repository-api-export-contract.test.js
new file mode 100644
index 0000000..aa9c226
--- /dev/null
+++ b/repository-api-export-contract/test/repository-api-export-contract.test.js
@@ -0,0 +1,81 @@
+import assert from "node:assert/strict"
+import { readFileSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+import { dirname, join } from "node:path"
+import {
+ buildRepositoryManifest,
+ createRepositoryApiExportContract,
+ validateRestApiPlan,
+} from "../src/repository-api-export-contract.js"
+
+const here = dirname(fileURLToPath(import.meta.url))
+const sample = JSON.parse(
+ readFileSync(join(here, "../data/sample-project.json"), "utf8"),
+)
+
+const contract = createRepositoryApiExportContract(sample)
+
+assert.equal(contract.readiness.ready, true)
+assert.equal(contract.manifest.blockers.length, 0)
+assert.equal(contract.manifest.requiredCoverage.manuscript, true)
+assert.equal(contract.manifest.requiredCoverage.data, true)
+assert.equal(contract.manifest.requiredCoverage.code, true)
+assert.equal(contract.manifest.requiredCoverage.notebooks, true)
+assert.equal(contract.manifest.requiredCoverage.results, true)
+assert.equal(contract.manifest.requiredCoverage.protocols, true)
+assert.equal(contract.manifest.requiredCoverage.metadata, true)
+assert.match(contract.manifest.integrityRoot, /^sha256:[a-f0-9]{64}$/)
+
+assert.deepEqual(contract.apiPlan.methods, ["GET", "POST", "PUT"])
+assert.equal(contract.apiPlan.exportRoute, "/api/projects/:repositoryId/export")
+assert.equal(contract.apiPlan.blockers.length, 0)
+
+assert.ok(
+ contract.exportBundle.entries.some((entry) => entry.path === "manifest.json"),
+)
+assert.ok(
+ contract.exportBundle.entries.some(
+ (entry) => entry.path === "citation/cite-this-project.json",
+ ),
+)
+assert.ok(
+ contract.exportBundle.entries.some(
+ (entry) => entry.path === "reproducibility/runbook.json",
+ ),
+)
+assert.match(contract.exportBundle.bundleHash, /^sha256:[a-f0-9]{64}$/)
+
+assert.ok(
+ contract.cliPlan.some((step) => step.command.startsWith("scibase repo clone")),
+)
+assert.ok(
+ contract.cliPlan.some((step) => step.command.includes("repo export")),
+)
+
+const missingMetadata = structuredClone(sample)
+missingMetadata.components = missingMetadata.components.filter(
+ (component) => component.path !== "metadata.json",
+)
+const missingMetadataManifest = buildRepositoryManifest(missingMetadata)
+assert.deepEqual(missingMetadataManifest.blockers, [
+ "missing required repository component: metadata",
+])
+
+const incompleteApi = validateRestApiPlan(
+ [{ method: "GET", path: "/api/projects/:repositoryId", scope: "project.read", public: true }],
+ contract.manifest,
+)
+assert.equal(incompleteApi.ready, false)
+assert.ok(incompleteApi.blockers.includes("missing public REST POST route"))
+assert.ok(incompleteApi.blockers.includes("missing public REST PUT route"))
+assert.ok(incompleteApi.blockers.includes("missing GET export route with integrity root"))
+
+const failingRepro = structuredClone(sample)
+failingRepro.reproducibility.status = "failing"
+const failingContract = createRepositoryApiExportContract(failingRepro)
+assert.equal(failingContract.readiness.ready, false)
+assert.ok(
+ failingContract.readiness.blockers.includes("reproducibility status is failing"),
+)
+
+console.log("repository-api-export-contract tests passed")