diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c13160d..1ae6c5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,30 @@ name: CI +# Runs on every push/PR to main. Mirrors the validation done by release.yml +# (Go build/vet/test/race/vuln scan and the browser .wasm extension) so a +# release never fails on something CI could have caught earlier. Release +# assets are published only by release.yml (exactly one writer to a release). + on: push: branches: [main] pull_request: branches: [main] + workflow_dispatch: permissions: contents: read +# Cancel superseded runs on the same branch/PR to save CI minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: build: name: Build, Vet & Test runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout @@ -24,8 +36,10 @@ jobs: go-version: "1.25.14" cache: true - - name: Download modules - run: go mod download + - name: Download and verify modules + run: | + go mod download + go mod verify - name: Verify go.mod / go.sum are tidy run: | @@ -56,6 +70,12 @@ jobs: - name: Test (race detector) run: go test -race ./... -count=1 + - name: Vet and test .ntx courier bridge + working-directory: transports/filerelay + run: | + go vet ./bridge/... + go test ./bridge/... -count=1 + # Keep govulncheck compatible with the project's Go version. # Do not install @latest because newer x/vuln releases may require # a newer Go toolchain than the project supports. @@ -66,3 +86,64 @@ jobs: - name: Vulnerability scan run: | govulncheck ./... + + wasm: + name: Browser extension (.wasm / .ntx) + runs-on: ubuntu-latest + timeout-minutes: 10 + # Pure Node: no Go, TinyGo or network needed, and output is byte-reproducible. + # Only the *extension* runs in the browser; relay servers (routers / shards / + # daemons) are never built for WebAssembly. + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Syntax check + working-directory: transports + run: | + set -euo pipefail + for f in build-wasm.mjs tools/*.mjs test/*.mjs */wasm/build.mjs; do + node --check "$f" + done + + - name: Tests (ABI v1 conformance, sandbox, policy, reproducibility) + working-directory: transports + run: node --test test/wasm-package.test.mjs + + - name: Build + working-directory: transports + run: node build-wasm.mjs + + - name: Reproducibility - a second build must be byte-identical + working-directory: transports + run: | + set -euo pipefail + cp dist/SHA256SUMS /tmp/first.sums + node build-wasm.mjs + diff /tmp/first.sums dist/SHA256SUMS + echo "reproducible:"; cat dist/SHA256SUMS + + - name: Package contents are exactly manifest.json + the .wasm entry + working-directory: transports + run: | + set -euo pipefail + for p in dist/*.ntx; do + echo "== $p"; unzip -tq "$p" + test "$(unzip -Z1 "$p" | wc -l)" -eq 2 + unzip -Z1 "$p" | grep -qx manifest.json + unzip -Z1 "$p" | grep -q '\.wasm$' + done + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: filerelay-wasm + path: | + transports/dist/*.ntx + transports/dist/SHA256SUMS + if-no-files-found: error \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83cf25a..1685eae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,21 @@ name: Release +# Releases are created from this workflow only. +# The workflow verifies the selected main commit with GitHub (signature), +# validates the Go code and the browser (.wasm) extension, builds the +# GoReleaser artifacts and the .ntx bundles (6 native + 1 browser), creates a +# DRAFT release targeting that exact commit, uploads every asset, then +# publishes the release. +# +# This workflow supersedes wasm-extension.yml: every check from it (syntax, +# ABI v1 conformance tests, build, reproducibility, package contents) now runs +# here as well as in ci.yml. +# +# The tag is NOT pushed manually. GoReleaser runs against the tag name via +# GORELEASER_CURRENT_TAG, and the tag is created by GitHub when the draft +# release is published. With release immutability enabled, a published tag is +# permanently bound to its release, so a failed run never leaves a stray tag. + on: workflow_dispatch: inputs: @@ -7,7 +23,6 @@ on: description: "Version to release, e.g. v1.4.0 or v1.4.0-rc.1" required: true type: string - prerelease: description: "Mark this release as a pre-release" required: false @@ -15,40 +30,76 @@ on: type: boolean permissions: - contents: write + contents: read concurrency: group: release-${{ inputs.version }} cancel-in-progress: false jobs: - release: - name: Publish release + validate: + name: Validate source runs-on: ubuntu-latest - - env: - TAG: ${{ inputs.version }} - PRERELEASE: ${{ inputs.prerelease }} + timeout-minutes: 25 + outputs: + commit_sha: ${{ steps.commit.outputs.sha }} steps: - - name: Validate version + - name: Validate version and release branch shell: bash + env: + TAG: ${{ inputs.version }} run: | set -euo pipefail + if [[ "${GITHUB_REF_NAME}" != "main" ]]; then + echo "::error::Releases must be cut from main. Selected ref: ${GITHUB_REF_NAME}" + exit 1 + fi + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "::error::Invalid version: $TAG" echo "::error::Expected format: v1.4.0 or v1.4.0-rc.1" exit 1 fi - echo "Release tag: $TAG" - echo "Pre-release: $PRERELEASE" - - - name: Checkout repository + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false + + - name: Capture validated commit + id: commit + shell: bash + run: | + set -euo pipefail + + SHA="$(git rev-parse HEAD)" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "Validated commit: $SHA" + + - name: Verify commit with GitHub + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + + verification="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}" \ + --jq '.commit.verification')" + + verified="$(jq -r '.verified' <<< "$verification")" + reason="$(jq -r '.reason' <<< "$verification")" + + if [[ "$verified" != "true" ]]; then + echo "::error::Commit $SHA is not verified by GitHub (reason: $reason)." + echo "::error::Releases must be cut from a signed, GitHub-verified commit." + exit 1 + fi + + echo "Commit $SHA is verified by GitHub (reason: $reason)." - name: Set up Go uses: actions/setup-go@v5 @@ -56,33 +107,226 @@ jobs: go-version: "1.25.14" cache: true - # ------------------------------------------------------------ - # Validate everything BEFORE creating/publishing the release. - # ------------------------------------------------------------ + - name: Go modules, formatting and build + shell: bash + run: | + set -euo pipefail + + go mod download + go mod verify + + go mod tidy + git diff --exit-code go.mod go.sum + + fmt_out="$(gofmt -l .)" + if [[ -n "$fmt_out" ]]; then + echo "::error::These files are not gofmt-formatted:" + echo "$fmt_out" + exit 1 + fi + + go vet ./... + go build ./... + + - name: Tests (including race detector) + shell: bash + run: | + set -euo pipefail + go test ./... -count=1 + go test -race ./... -count=1 + + - name: Vulnerability scan + shell: bash + run: | + set -euo pipefail + go install golang.org/x/vuln/cmd/govulncheck@v1.7.0 + govulncheck ./... - name: Vet and test bridge working-directory: transports/filerelay shell: bash run: | set -euo pipefail - go vet ./bridge/... go test ./bridge/... -count=1 - - name: Cross-compile and package .ntx bundles - working-directory: transports/filerelay + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: WASM extension - syntax check + working-directory: transports shell: bash run: | set -euo pipefail + for f in build-wasm.mjs tools/*.mjs test/*.mjs */wasm/build.mjs; do + node --check "$f" + done - VERSION="${TAG#v}" - LDFLAGS="-s -w" + - name: WASM extension - tests (ABI v1 conformance, sandbox, policy, reproducibility) + working-directory: transports + shell: bash + run: | + set -euo pipefail + node --test test/wasm-package.test.mjs + + - name: WASM extension - build twice and require byte-identical output + working-directory: transports + shell: bash + run: | + set -euo pipefail + + node build-wasm.mjs --out "$RUNNER_TEMP/wasm-check-1" + node build-wasm.mjs --out "$RUNNER_TEMP/wasm-check-2" + + diff -r "$RUNNER_TEMP/wasm-check-1" "$RUNNER_TEMP/wasm-check-2" + echo "WASM build is reproducible." + + for p in "$RUNNER_TEMP"/wasm-check-1/*.ntx; do + echo "== $p" + unzip -tq "$p" + test "$(unzip -Z1 "$p" | wc -l)" -eq 2 + unzip -Z1 "$p" | grep -qx manifest.json + unzip -Z1 "$p" | grep -q '\.wasm$' + done + + - name: Verify validation left no worktree changes + shell: bash + run: | + set -euo pipefail + + if [[ -n "$(git status --porcelain)" ]]; then + echo "::error::Validation modified the Git worktree." + git status --short + exit 1 + fi + + release: + name: Build and publish + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 40 + + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + + env: + TAG: ${{ inputs.version }} + PRERELEASE: ${{ inputs.prerelease }} + RELEASE_SHA: ${{ needs.validate.outputs.commit_sha }} + + steps: + - name: Checkout validated release source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.validate.outputs.commit_sha }} + + - name: Verify validated release commit + shell: bash + run: | + set -euo pipefail + + ACTUAL_SHA="$(git rev-parse HEAD)" + + if [[ "$ACTUAL_SHA" != "$RELEASE_SHA" ]]; then + echo "::error::Checked out commit does not match the validated commit." + echo "::error::Expected: $RELEASE_SHA" + echo "::error::Actual: $ACTUAL_SHA" + exit 1 + fi + + echo "Using validated commit: $RELEASE_SHA" + + - name: Re-verify release commit with GitHub + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + verification="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_SHA}" \ + --jq '.commit.verification')" + + verified="$(jq -r '.verified' <<< "$verification")" + reason="$(jq -r '.reason' <<< "$verification")" + + if [[ "$verified" != "true" ]]; then + echo "::error::Commit $RELEASE_SHA is not verified by GitHub (reason: $reason)." + exit 1 + fi + + echo "Commit $RELEASE_SHA is verified by GitHub (reason: $reason)." + + - name: Refuse to overwrite a published or immutable release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if release_json="$(gh release view "$TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isImmutable 2>/dev/null)"; then + + is_draft="$(jq -r '.isDraft' <<< "$release_json")" + is_immutable="$(jq -r '.isImmutable' <<< "$release_json")" + + if [[ "$is_draft" != "true" || "$is_immutable" == "true" ]]; then + echo "::error::Release $TAG already exists as a published or immutable release." + echo "::error::Immutable release versions are one-shot. Use a new version." + exit 1 + fi + fi + + if git ls-remote --exit-code --refs origin "refs/tags/$TAG" >/dev/null 2>&1; then + git fetch origin "refs/tags/$TAG:refs/tags/$TAG" + TAG_SHA="$(git rev-list -n 1 "$TAG")" - ROOT="$(git rev-parse --show-toplevel)" - DIST="$ROOT/bridge-dist" + if [[ "$TAG_SHA" != "$RELEASE_SHA" ]]; then + echo "::error::Tag $TAG already exists at a different commit." + echo "::error::Tag commit: $TAG_SHA" + echo "::error::Release commit: $RELEASE_SHA" + exit 1 + fi + fi + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.14" + cache: true + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Prepare bundle directory + shell: bash + run: | + set -euo pipefail + + export BRIDGE_DIST="$RUNNER_TEMP/filerelay-bridge-dist" + echo "BRIDGE_DIST=$BRIDGE_DIST" >> "$GITHUB_ENV" - rm -rf "$DIST" - mkdir -p "$DIST" + rm -rf "$BRIDGE_DIST" + mkdir -p "$BRIDGE_DIST" + + - name: Cross-compile and package six native .ntx bundles + working-directory: transports/filerelay + shell: bash + env: + GOFLAGS: -mod=readonly + run: | + set -euo pipefail + + VERSION="${TAG#v}" targets=( "linux amd64" @@ -112,42 +356,30 @@ jobs: mkdir -p .ntx-stage if [[ -n "${GOARM:-}" ]]; then - GOOS="$GOOS" \ - GOARCH="$GOARCH" \ - GOARM="$GOARM" \ - CGO_ENABLED=0 \ - go build \ - -ldflags "$LDFLAGS" \ - -o "$ENTRY" \ - ./bridge + GOOS="$GOOS" GOARCH="$GOARCH" GOARM="$GOARM" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" -o "$ENTRY" ./bridge else - GOOS="$GOOS" \ - GOARCH="$GOARCH" \ - CGO_ENABLED=0 \ - go build \ - -ldflags "$LDFLAGS" \ - -o "$ENTRY" \ - ./bridge + GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w" -o "$ENTRY" ./bridge fi test -s "$ENTRY" - jq \ - --arg version "$VERSION" \ + jq --arg version "$VERSION" \ '.version = $version' \ "$MANIFEST" \ > .ntx-stage/manifest.json jq -e \ --arg version "$VERSION" \ - '.version == $version' \ - .ntx-stage/manifest.json \ - >/dev/null + --arg entry "$ENTRY" \ + '.version == $version and .entry == $entry' \ + .ntx-stage/manifest.json >/dev/null cp "$ENTRY" .ntx-stage/ SUFFIX="${GOOS}_${GOARCH}${GOARM:+v${GOARM}}" - NTX="$DIST/filerelay-bridge_${VERSION}_${SUFFIX}.ntx" + NTX="$BRIDGE_DIST/filerelay-bridge_${VERSION}_${SUFFIX}.ntx" ( cd .ntx-stage @@ -156,108 +388,105 @@ jobs: test -s "$NTX" unzip -tq "$NTX" + test "$(unzip -Z1 "$NTX" | wc -l)" -eq 2 + unzip -Z1 "$NTX" | grep -qx 'manifest.json' + unzip -Z1 "$NTX" | grep -Fqx "$ENTRY" rm -rf .ntx-stage "$ENTRY" echo "Packaged: $NTX" done + count="$(find "$BRIDGE_DIST" -maxdepth 1 -type f -name 'filerelay-bridge_*.ntx' | wc -l)" + if [[ "$count" -ne 6 ]]; then + echo "::error::Expected 6 native .ntx bundles, found $count." + find "$BRIDGE_DIST" -maxdepth 1 -type f -print + exit 1 + fi + echo - echo "==> Generated bridge bundles:" - ls -lah "$DIST" + echo "==> Six native .ntx bundles verified:" + ls -lh "$BRIDGE_DIST" - - name: Generate bridge checksums - working-directory: bridge-dist + - name: Build and verify browser (.wasm) extension + working-directory: transports shell: bash run: | set -euo pipefail - sha256sum \ - filerelay-bridge_*.ntx \ - > filerelay-bridge_checksums.txt - - test -s filerelay-bridge_checksums.txt - - echo "==> Bridge checksums:" - cat filerelay-bridge_checksums.txt - - - name: Verify bridge bundle count + VERSION="${TAG#v}" + WASM_TMP="$RUNNER_TEMP/wasm-dist" + WASM_TMP2="$RUNNER_TEMP/wasm-dist-repro" + + node build-wasm.mjs --version "$VERSION" --out "$WASM_TMP" + + # Reproducibility: a second build must be byte-identical. + node build-wasm.mjs --version "$VERSION" --out "$WASM_TMP2" + diff -r "$WASM_TMP" "$WASM_TMP2" + echo "WASM build is reproducible." + + OUT="$BRIDGE_DIST/filerelay-wasm_${VERSION}_browser.ntx" + cp "$WASM_TMP/filerelay-wasm.ntx" "$OUT" + + # Package contents are exactly manifest.json + the .wasm entry. + test -s "$OUT" + unzip -tq "$OUT" + test "$(unzip -Z1 "$OUT" | wc -l)" -eq 2 + unzip -Z1 "$OUT" | grep -qx 'manifest.json' + unzip -Z1 "$OUT" | grep -qx 'filerelay.wasm' + + unzip -p "$OUT" manifest.json \ + | jq -e --arg version "$VERSION" ' + .id == "filerelay-wasm" + and .version == $version + and .api_version == "1" + and .entry == "filerelay.wasm" + and (.permissions | length) == 0 + ' >/dev/null + + echo "Packaged: $OUT" + + - name: Generate and verify bundle checksums shell: bash run: | set -euo pipefail - count="$(find bridge-dist \ - -maxdepth 1 \ - -type f \ - -name 'filerelay-bridge_*.ntx' \ - | wc -l)" + cd "$BRIDGE_DIST" - if [[ "$count" -ne 6 ]]; then - echo "::error::Expected 6 .ntx bundles, found $count." - find bridge-dist -maxdepth 1 -type f -print - exit 1 - fi + sha256sum filerelay-bridge_*.ntx filerelay-wasm_*.ntx \ + > filerelay-bridge_checksums.txt + sha256sum -c filerelay-bridge_checksums.txt - echo "Verified $count bridge bundles." + test -s filerelay-bridge_checksums.txt + # six native bundles + the browser (.wasm) bundle + test "$(wc -l < filerelay-bridge_checksums.txt)" -eq 7 - # ------------------------------------------------------------ - # Tag only after all bridge artifacts have been validated. - # ------------------------------------------------------------ + echo "==> Bundle checksums:" + cat filerelay-bridge_checksums.txt - - name: Create release tag + - name: Final source sanity check shell: bash run: | set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Tag $TAG already exists." - - git fetch origin "refs/tags/$TAG:refs/tags/$TAG" - - CURRENT_SHA="$(git rev-parse HEAD)" - TAG_SHA="$(git rev-list -n 1 "$TAG")" - - if [[ "$CURRENT_SHA" != "$TAG_SHA" ]]; then - echo "::error::Tag $TAG already exists but points to a different commit." - echo "::error::Current commit: $CURRENT_SHA" - echo "::error::Tag commit: $TAG_SHA" - exit 1 - fi - else - git tag "$TAG" - git push origin "$TAG" - echo "Created and pushed tag $TAG." + if [[ -n "$(git status --porcelain)" ]]; then + echo "::error::Release build modified the source worktree." + git status --short + exit 1 fi - - name: Checkout release tag - shell: bash - run: | - set -euo pipefail - - git fetch --tags origin - git checkout "$TAG" - - echo "Checked out:" - git rev-parse HEAD - - echo "Tag:" - git describe --tags --exact-match - - # ------------------------------------------------------------ - # Build GoReleaser artifacts, but DO NOT publish yet. - # ------------------------------------------------------------ - + # GoReleaser is pointed at the tag name without creating the tag. + # validate is skipped because there is no local tag yet; the commit and + # worktree were already verified above. - name: Run GoReleaser without publishing uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: latest - args: release --clean --skip=publish + version: "~> v2" + args: release --clean --skip=publish,validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ inputs.version }} - name: Verify GoReleaser output shell: bash @@ -274,34 +503,82 @@ jobs: exit 1 fi - # ------------------------------------------------------------ - # Create ONE draft release. - # Nothing is immutable yet because it is still a draft. - # ------------------------------------------------------------ + - name: Generate bundle attestation + uses: actions/attest@v4 + with: + subject-checksums: ${{ env.BRIDGE_DIST }}/filerelay-bridge_checksums.txt - - name: Create draft GitHub release + - name: Upload bundles as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: filerelay-ntx + path: | + ${{ env.BRIDGE_DIST }}/*.ntx + ${{ env.BRIDGE_DIST }}/filerelay-bridge_checksums.txt + if-no-files-found: error + + - name: Prepare draft GitHub release + id: prepare shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - NOTES_ARGS=() + if gh release view "$TAG" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + # The earlier step already proved this is a mutable draft. + echo "Reusing existing draft release $TAG." + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + NOTES_ARGS=() if [[ -f dist/CHANGELOG.md ]]; then NOTES_ARGS+=(--notes-file dist/CHANGELOG.md) else NOTES_ARGS+=(--generate-notes) fi - gh release create "$TAG" \ + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "Creating draft release for existing tag $TAG." + gh release create "$TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft \ + --verify-tag \ + "${NOTES_ARGS[@]}" + else + echo "Creating draft release $TAG at $RELEASE_SHA." + gh release create "$TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft \ + --target "$RELEASE_SHA" \ + "${NOTES_ARGS[@]}" + fi + + echo "created=true" >> "$GITHUB_OUTPUT" + + - name: Verify draft release target + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + release_json="$(gh release view "$TAG" \ --repo "${GITHUB_REPOSITORY}" \ - --draft \ - "${NOTES_ARGS[@]}" + --json isDraft,isImmutable,tagName,targetCommitish)" - # ------------------------------------------------------------ - # Upload GoReleaser artifacts while the release is still draft. - # ------------------------------------------------------------ + jq -e \ + --arg tag "$TAG" \ + --arg sha "$RELEASE_SHA" \ + ' + .isDraft == true + and .isImmutable == false + and .tagName == $tag + and .targetCommitish == $sha + ' <<< "$release_json" >/dev/null + + echo "Verified draft release $TAG targets $RELEASE_SHA." - name: Upload GoReleaser artifacts shell: bash @@ -313,6 +590,8 @@ jobs: mapfile -t RELEASE_ASSETS < <( find dist -maxdepth 1 -type f \ ! -name 'artifacts.json' \ + ! -name 'metadata.json' \ + ! -name 'config.yaml' \ ! -name 'CHANGELOG.md' \ -print \ | sort @@ -326,46 +605,40 @@ jobs: echo "Uploading GoReleaser assets:" printf ' %s\n' "${RELEASE_ASSETS[@]}" - gh release upload \ - "$TAG" \ - "${RELEASE_ASSETS[@]}" \ - --repo "${GITHUB_REPOSITORY}" - - # ------------------------------------------------------------ - # Upload all six .ntx bridge bundles + checksums. - # ------------------------------------------------------------ + gh release upload "$TAG" "${RELEASE_ASSETS[@]}" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber - - name: Upload bridge bundles + - name: Upload bundle assets shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - mapfile -t BRIDGE_ASSETS < <( - find bridge-dist -maxdepth 1 -type f \ - \( -name '*.ntx' -o -name 'filerelay-bridge_checksums.txt' \) \ - -print \ - | sort + V="${TAG#v}" + + assets=( + "$BRIDGE_DIST/filerelay-bridge_${V}_linux_amd64.ntx" + "$BRIDGE_DIST/filerelay-bridge_${V}_linux_arm64.ntx" + "$BRIDGE_DIST/filerelay-bridge_${V}_linux_armv7.ntx" + "$BRIDGE_DIST/filerelay-bridge_${V}_darwin_amd64.ntx" + "$BRIDGE_DIST/filerelay-bridge_${V}_darwin_arm64.ntx" + "$BRIDGE_DIST/filerelay-bridge_${V}_windows_amd64.ntx" + "$BRIDGE_DIST/filerelay-wasm_${V}_browser.ntx" + "$BRIDGE_DIST/filerelay-bridge_checksums.txt" ) - if [[ "${#BRIDGE_ASSETS[@]}" -ne 7 ]]; then - echo "::error::Expected 7 bridge assets (6 .ntx + 1 checksum), found ${#BRIDGE_ASSETS[@]}." - printf ' %s\n' "${BRIDGE_ASSETS[@]}" - exit 1 - fi - - echo "Uploading bridge assets:" - printf ' %s\n' "${BRIDGE_ASSETS[@]}" + for asset in "${assets[@]}"; do + test -s "$asset" + done - gh release upload \ - "$TAG" \ - "${BRIDGE_ASSETS[@]}" \ - --repo "${GITHUB_REPOSITORY}" + echo "Uploading bundle assets:" + printf ' %s\n' "${assets[@]}" - # ------------------------------------------------------------ - # Final verification while the release is STILL a draft. - # ------------------------------------------------------------ + gh release upload "$TAG" "${assets[@]}" \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber - name: Verify draft release assets shell: bash @@ -374,36 +647,38 @@ jobs: run: | set -euo pipefail - echo "==> Release assets:" + V="${TAG#v}" + gh release view "$TAG" \ --repo "${GITHUB_REPOSITORY}" \ --json assets \ - --jq '.assets[].name' + --jq '.assets[].name' \ + | sort > "$RUNNER_TEMP/actual-assets.txt" - EXPECTED=( - "filerelay-bridge_*.ntx" + echo "==> Release assets:" + cat "$RUNNER_TEMP/actual-assets.txt" + + expected=( + "filerelay-bridge_${V}_darwin_amd64.ntx" + "filerelay-bridge_${V}_darwin_arm64.ntx" + "filerelay-bridge_${V}_linux_amd64.ntx" + "filerelay-bridge_${V}_linux_arm64.ntx" + "filerelay-bridge_${V}_linux_armv7.ntx" + "filerelay-bridge_${V}_windows_amd64.ntx" + "filerelay-wasm_${V}_browser.ntx" "filerelay-bridge_checksums.txt" ) - for pattern in "${EXPECTED[@]}"; do - if ! gh release view "$TAG" \ - --repo "${GITHUB_REPOSITORY}" \ - --json assets \ - --jq '.assets[].name' \ - | grep -Eq "^${pattern//\*/.*}$"; then - echo "::error::Missing release asset matching: $pattern" + for name in "${expected[@]}"; do + if ! grep -Fxq "$name" "$RUNNER_TEMP/actual-assets.txt"; then + echo "::error::Missing release asset: $name" exit 1 fi done echo "Draft release assets verified." - # ------------------------------------------------------------ - # ONLY NOW publish the release. - # With release immutability enabled, this is the final mutation. - # ------------------------------------------------------------ - - - name: Publish release + - name: Publish immutable release shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -418,7 +693,70 @@ jobs: else gh release edit "$TAG" \ --repo "${GITHUB_REPOSITORY}" \ - --draft=false + --draft=false \ + --prerelease=false + fi + + echo "Published $TAG" + + - name: Verify immutable release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + release_json="$(gh release view "$TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isImmutable,isPrerelease,tagName,targetCommitish,assets,url)" + + jq -e \ + --arg tag "$TAG" \ + --arg sha "$RELEASE_SHA" \ + --argjson prerelease "$PRERELEASE" \ + ' + .isDraft == false + and .isImmutable == true + and .tagName == $tag + and .targetCommitish == $sha + and .isPrerelease == $prerelease + ' <<< "$release_json" >/dev/null + + echo "Immutable release verified:" + jq -r '"\(.url)\nassets=\(.assets | length)\ntarget=\(.targetCommitish)"' <<< "$release_json" + + - name: Verify GitHub release attestation + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh release verify "$TAG" --repo "${GITHUB_REPOSITORY}" + + - name: Cleanup draft release after failure + if: ${{ (failure() || cancelled()) && steps.prepare.outputs.created == 'true' }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + + release_json="$(gh release view "$TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --json isDraft,isImmutable 2>/dev/null)" + + if [[ -z "$release_json" ]]; then + echo "Release $TAG is not readable; leaving state untouched." + exit 0 fi - echo "Release $TAG published." + is_draft="$(jq -r '.isDraft' <<< "$release_json")" + is_immutable="$(jq -r '.isImmutable' <<< "$release_json")" + + if [[ "$is_draft" == "true" && "$is_immutable" != "true" ]]; then + echo "Cleaning up draft release $TAG." + # Never delete a pre-existing tag as part of draft cleanup. + gh release delete "$TAG" --repo "${GITHUB_REPOSITORY}" --yes || true + else + echo "Release $TAG is published or immutable; refusing destructive cleanup." + fi \ No newline at end of file diff --git a/README.md b/README.md index 4659ed7..076b864 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,132 @@ [![Release](https://github.com/erfanheydarzade/NexTalk-FileRelay/actions/workflows/release.yml/badge.svg)](https://github.com/erfanheydarzade/NexTalk-FileRelay/releases) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -> A message + file relay for [NexTalk](https://github.com/erfanheydarzade/NexTalk) — opaque, untrusted transport for encrypted messages and chunked file transfers, speaking a frozen binary protocol (NanoPack) over HTTP. +> A message + file relay for [NexTalk](https://github.com/erfanheydarzade/NexTalk): opaque, untrusted transport for encrypted messages and chunked file transfers, speaking a frozen binary protocol (NanoPack) over HTTP. -FileRelay never sees plaintext, never holds E2E keys, and never performs -NexTalk's encrypt/decrypt. It moves opaque bytes between mailboxes derived -from a client's public key, with burn-after-read messages, resumable -chunked file transfers, and a full authentication/replay/quota model. See -[`DESIGN.md`](DESIGN.md) for the architecture and rationale, and -[`docs/server.md`](docs/server.md) for the deployed HTTP API reference. +FileRelay never sees plaintext, never holds E2E keys, and never performs NexTalk's encrypt/decrypt. It moves opaque bytes between mailboxes derived from a client's public key, with burn-after-read messages, resumable chunked file transfers, and a full authentication/replay/quota model. See [`DESIGN.md`](DESIGN.md) for the architecture and rationale, and [`docs/server.md`](docs/server.md) for the deployed HTTP API reference. -> **Security status:** the protocol's security properties are enforced by this -> implementation, but the project **has not undergone an independent security -> audit**. "Secure" here is a design claim, not an audited assurance. +> **Security status:** the protocol's security properties are enforced by this implementation, but the project **has not undergone an independent security audit**. "Secure" here is a design claim, not an audited assurance. + +- [Prebuilt packages](#prebuilt-packages-no-build-needed) +- [Quick start: run the server](#quick-start-run-the-server) · [Quick start: use it from NexTalk](#quick-start-use-it-from-nextalk) +- [Which command do I need?](#which-command-do-i-need) · [Troubleshooting](#troubleshooting) +- [Architecture](#architecture) · [Features](#features) +- [Server configuration](#server-configuration) · [API](#api) +- [NexTalk transport (`.ntx`) & browser package](#nextalk-transport-the-ntx-bridge) · [Security notes](#security-notes) + +--- + +## Prebuilt packages (no build needed) + +You don't have to compile anything to use FileRelay from NexTalk. The [**Releases page**](https://github.com/erfanheydarzade/NexTalk-FileRelay/releases/latest) has the ready-made `filerelay` transport packages (`.ntx`), the browser `filerelay-wasm.ntx` demo package, and a SHA-256 checksum file. Download the `.ntx` for your OS/CPU, verify it, and install it: + +```bash +nextalk transport install .ntx --enable +``` + +The **server** (`filerelayd`) is separate from the transport. Build it from source as below unless the release also lists a `filerelayd` binary for your platform. + +--- + +## Quick start: run the server + +```bash +# Build from source +go build -o filerelayd ./cmd/filerelayd + +# Run a demo single-binary deployment (router + shard, one shared store) +./filerelayd --addr 127.0.0.1:8080 +# Without --server-secret a fresh secret is generated and printed. Save it: +# rotating it orphans every mailbox. + +# Print version info +./filerelayd --version +``` + +For production multi-shard deployments, construct `router.NewHandler` and `shard.NewHandler` against shared KV-backed `storage.Store` implementations directly. See [`docs/server.md`](docs/server.md). + +--- + +## Quick start: use it from NexTalk + +The server above is what NexTalk talks to through the installable `filerelay` transport. There are two separate things you might want, and they use different mailboxes. + +### A. Send and receive files + +```bash +# Install the bridge (prebuilt .ntx from Releases, or one you built) and point it at your server +nextalk transport install filerelay.ntx --enable +nextalk transport config filerelay '{"router_url":"http://127.0.0.1:8080"}' + +# Mint a file-transfer mailbox (auto-attached) +nextalk transport register filerelay --id --router http://127.0.0.1:8080 + +# Sender uploads; recipient downloads with the ticket +nextalk transport xfer-send filerelay -i --to --mailbox --shard -f photo.bin +nextalk transport xfer-recv filerelay -i --from --ticket -o photo.bin +``` + +PowerShell needs the inner JSON quotes escaped: + +```powershell +.\nextalk transport config filerelay '{\"router_url\":\"http://127.0.0.1:8080\"}' +``` + +### B. Chat by peer ID + +Each person registers their **real identity** on the same server, then connects: + +```bash +nextalk transport register-identity filerelay -i --router http://127.0.0.1:8080 +nextalk transport resolve filerelay --to --router http://127.0.0.1:8080 # verify +``` + +``` +identity load +use relay filerelay +peer connect # the peer must have run register-identity too +transport poll # both sides poll to finish the handshake +message send hello +``` + +Build, packaging, the offline two-user scenario and every `xfer-*` command are in [`transports/filerelay/README.md`](transports/filerelay/README.md). + +--- + +## Which command do I need? + +| I want to… | Use | Why | +|---|---|---| +| Move files without tying a mailbox to my identity | `transport register` (alias `xfer-register`) | Registers a throwaway key owned by the bridge. Nobody can compute the address from your peer ID, so you share mailbox + shard, or a download ticket, yourself. | +| Chat with someone using only their peer ID | `transport register-identity` | Registers your real identity key. Anyone can compute your mailbox from your peer ID, so `peer connect` finds you automatically. | +| Check whether a peer is reachable | `transport resolve --to --router ` | Asks the router where that identity's mailbox is. | + +**Most common mistake:** running `transport register` and expecting `peer connect` to work. They use different mailboxes. You can use both side by side. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `recipient mailbox is not registered on the relay` | The **peer** has no identity mailbox on this server. | They run `transport register-identity` against the **same** server. Check with `transport resolve --to `. | +| Every command fails with `replay attach ` | NexTalk replays saved mailboxes on start, and they are stale after the server was reset or `--server-secret` changed. | `transport detach filerelay --mailbox <32-hex>` for each, then register again. | +| Registered, but nobody can find you after a restart | The server started without `--server-secret`, so it generated a **new** one. Mailbox IDs are `HMAC-SHA256(SERVER_SECRET, pubkey)`, so every old mailbox is orphaned. | Always pass the same `--server-secret`. Save the one it printed the first time. | +| `filerelay transport does not support identity registration` | The installed `filerelay.ntx` is older than your NexTalk core. | Rebuild, package and reinstall the bridge. | +| Works on one server, not another | Registrations are per server. | Use the same `router_url` on both sides. | +| Authentication errors | Client and server clocks differ by more than ±30 s. | Fix the system clock. | +| HTTP 429 | A rate limit (sender / target / IP / transfer) was hit. | Wait for `retry_after_s` from the response. | + +`router_url` may be the server root or the `/fr/v1/` API root, with or without a trailing slash. The bridge normalizes all of these. + +--- ## Architecture Two processes serve the protocol: -- **Router** — derives opaque 16-byte mailbox IDs (`HMAC-SHA256(SERVER_SECRET, pubkey)`), - publishes a signed routing table, and answers `register`/`resolve`. It never - proxies message or chunk bodies. -- **Shard** — untrusted, dumb opaque storage: mailboxes, message backlog, - transfer/chunk state, TTLs, quotas, rate limits. It never learns the - recipient's real public key. +- **Router**: derives opaque 16-byte mailbox IDs (`HMAC-SHA256(SERVER_SECRET, pubkey)`), publishes a signed routing table, and answers `register`/`resolve`. It never proxies message or chunk bodies. +- **Shard**: untrusted, dumb opaque storage: mailboxes, message backlog, transfer/chunk state, TTLs, quotas, rate limits. It never learns the recipient's real public key. ```text NexTalk core (crypto/session/ratchet) @@ -35,51 +138,36 @@ NexTalk core (crypto/session/ratchet) FileRelay client adapter / bridge → Router (register/resolve/table) │ NanoPack binary bodies over HTTP │ ▼ ▼ -Shard(s) — opaque mailbox/message/chunk storage (KV) +Shard(s): opaque mailbox/message/chunk storage (KV) ``` -Everything on the wire is raw NanoPack (`Content-Type: application/x-nanopack`), -not JSON — see [`FRAMING.md`](FRAMING.md) for the framing decision and -`docs/server.md` for the full endpoint/schema table. +Everything on the wire is raw NanoPack (`Content-Type: application/x-nanopack`), not JSON. See [`FRAMING.md`](FRAMING.md) for the framing decision and `docs/server.md` for the full endpoint/schema table. ## Features -- **Opaque by design**: the relay never decrypts anything; envelopes and - chunks are carried verbatim and hash-verified by the client. -- **Burn-after-read messaging**: `SendMessage` → `ReceiveMessages` → `MessageAck`, - with `peek` support and TTL-based lazy expiry. -- **Resumable chunked file transfers**: create → put (pipelined, idempotent) → - resume (bitmap or ranges) → complete → get, up to 1 GiB per file. -- **Sender-owned transfer tickets (v1.1)**: share a download-only capability - inside an ordinary E2E message instead of binding a transfer to the - recipient's mailbox. -- **Ed25519 auth + replay protection**: domain-separated canonical binary - signatures, ±30s clock skew, single-use nonce cache. -- **Bounded everything**: per-message, per-chunk, per-file, per-mailbox, and - per-request size caps, all enforced before buffers are allocated. - -## Quick Start +- **Opaque by design**: the relay never decrypts anything; envelopes and chunks are carried verbatim and hash-verified by the client. +- **Burn-after-read messaging**: `SendMessage` → `ReceiveMessages` → `MessageAck`, with `peek` support and TTL-based lazy expiry. +- **Resumable chunked file transfers**: create → put (pipelined, idempotent) → resume (bitmap or ranges) → complete → get, up to 1 GiB per file. +- **Sender-owned transfer tickets (v1.1)**: share a download-only capability inside an ordinary E2E message instead of binding a transfer to the recipient's mailbox. +- **Ed25519 auth + replay protection**: domain-separated canonical binary signatures, ±30 s clock skew, single-use nonce cache. +- **Bounded everything**: per-message, per-chunk, per-file, per-mailbox and per-request size caps, all enforced before buffers are allocated. -```bash -# Build from source -go build -o filerelayd ./cmd/filerelayd +--- -# Run a demo single-binary deployment (router + shard, one shared store) -./filerelayd --addr 127.0.0.1:8080 -# Without --server-secret a fresh secret is generated and printed — save it: -# rotating it orphans every mailbox. +## Server configuration -# Print version info -./filerelayd --version -``` +`filerelayd` flags: -Flags: `--addr` (default `127.0.0.1:8080`), `--server-secret` (64 hex chars, -generated if omitted), `--print-template` (print `ROUTER_URL`/`SHARD_URL` -and exit), `--version`. +| Flag | Default | Description | +|---|---|---| +| `--addr` | `127.0.0.1:8080` | Listen address | +| `--server-secret` | generated and printed | 64 hex chars. **Keep it stable**: rotating it orphans every mailbox. | +| `--print-template` | off | Print `ROUTER_URL` / `SHARD_URL` and exit | +| `--version` | off | Print version info and exit | -For production multi-shard deployments, construct `router.NewHandler` and -`shard.NewHandler` against shared KV-backed `storage.Store` implementations -directly — see [`docs/server.md`](docs/server.md). +On the NexTalk side, the only setting is `router_url` (`transport config filerelay '{"router_url":"…"}'`, or `--router` per command). + +--- ## API @@ -100,30 +188,24 @@ POST /fr/v1/xfer/complete Seal a transfer once all chunks are present POST /fr/v1/xfer/cancel Cancel/expire a transfer early ``` -Full request/response schema table, auth requirements, and error codes are -in [`docs/server.md`](docs/server.md). Requests failing validation return -HTTP 4xx/5xx with a typed `Error` body — clients should switch on the -numeric error code, never on the debug detail string. +Full request/response schema table, auth requirements and error codes are in [`docs/server.md`](docs/server.md). Requests failing validation return HTTP 4xx/5xx with a typed `Error` body. Clients should switch on the numeric error code, never on the debug detail string. + +--- + +## NexTalk transport (the `.ntx` bridge) + +`transports/filerelay/` packages a standalone courier bridge that NexTalk's CLI installs as a transport plugin (`nextalk transport install filerelay.ntx`), with no NexTalk rebuild required. See [`transports/filerelay/README.md`](transports/filerelay/README.md) for build, packaging and installation steps, and its trust model (throwaway courier identity, never sees NexTalk's private keys). -## NexTalk Transport (the `.ntx` bridge) +After rebuilding the bridge you must **reinstall** it: it is a separate executable, so a new `nextalk.exe` does not update it. -`transports/filerelay/` packages a standalone courier bridge that NexTalk's -CLI installs as a transport plugin (`nextalk transport install filerelay.ntx`) -— no NexTalk rebuild required. See -[`transports/filerelay/README.md`](transports/filerelay/README.md) for the -build, packaging, and installation steps, and its trust model (throwaway -courier identity, never sees NexTalk's private keys). +The extension also has a browser (WebAssembly) package, `filerelay-wasm.ntx`, built by `transports/build-wasm.mjs` with Node alone and attached to every release. Under WASM transport ABI v1 it is an in-tab loopback queue (messages only; the ABI has no network or file-transfer ops). The native bridge is what talks to a relay. Only the extension targets the browser: the router/shard server never runs there. Details in the transport README. -## Security Notes +## Security notes -- The relay never stores or transmits plaintext; it only ever sees what the - client already encrypted. -- Every sender-authenticated request is Ed25519-signed over canonical binary - bytes (never hex/JSON) and checked against a replay cache. +- The relay never stores or transmits plaintext; it only ever sees what the client already encrypted. +- Every sender-authenticated request is Ed25519-signed over canonical binary bytes (never hex/JSON) and checked against a replay cache. - Burn-after-read is the default for messages; `peek` is explicit. -- Chunks and the whole-transfer manifest are hash-verified by the receiving - client — the relay's integrity guarantee is "returns exactly what was - stored," not "verified plaintext." +- Chunks and the whole-transfer manifest are hash-verified by the receiving client. The relay's integrity guarantee is "returns exactly what was stored," not "verified plaintext." - Rate limits (sender/target/IP/transfer) return HTTP 429 with `retry_after_s`. ## Contributing @@ -132,4 +214,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## License -Apache 2.0 — see [LICENSE](LICENSE). +Apache 2.0. See [LICENSE](LICENSE). \ No newline at end of file diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 14e933d..855e6da 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -58,13 +58,19 @@ Each release produces: - `filerelay-bridge_v0.1.0__.ntx` — the NexTalk transport bridge (`transports/filerelay/`), packaged per OS/arch with its manifest's `version` field stamped to the release tag, for every supported OS/arch -- `filerelay-bridge_checksums.txt` with SHA256 hashes of the `.ntx` bundles - -The `.ntx` bundles are built and uploaded by the `package-bridge` job in -`.github/workflows/release.yml` after GoReleaser publishes the server -archives — it cross-compiles `filerelay-bridge`, refuses to package if -`go vet`/`go test` on the bridge fail, and uploads the results to the same -GitHub Release via `gh release upload --clobber`. +- `filerelay-wasm_v0.1.0_browser.ntx` — the browser (WebAssembly) package of the + extension (`transports/filerelay/wasm/`), built with Node only and stamped with + the same version. Under WASM transport ABI v1 it is an in-tab loopback queue + (messages only), a complement to the native bundles rather than a replacement. +- `filerelay-bridge_checksums.txt` with SHA256 hashes of all seven `.ntx` bundles + (six native + the browser one) + +The `.ntx` bundles are built in the single `release` job of +`.github/workflows/release.yml`, *before* the release tag is created: it +cross-compiles `filerelay-bridge`, refuses to package if `go vet`/`go test` +on the bridge fail, tests and builds the browser package +(`transports/build-wasm.mjs`), and later uploads everything to the draft +GitHub Release together with the GoReleaser archives. ## Versioning diff --git a/transports/build-wasm.mjs b/transports/build-wasm.mjs new file mode 100644 index 0000000..10d3f62 --- /dev/null +++ b/transports/build-wasm.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Build the browser (WebAssembly) package of every extension under transports/ +// into transports/dist/*.ntx — no Go, TinyGo, wat2wasm or network needed. +// +// Discovery (no registry to maintain): every directory next to this file that +// has a wasm/manifest.json is a browser extension. +// +// /wasm/manifest.json browser package manifest (id must differ from the native one) +// /wasm/build.mjs node build.mjs -> must write / +// /wasm/verify.json optional { "loopback": true } extra conformance checks +// +// Every package is validated with NexTalk's own manifest rules and ABI-v1-verified +// before it is written. Output is reproducible (fixed timestamps), so the sha256 +// NexTalk pins at install is stable; dist/SHA256SUMS lists every pin. +// +// The native (process-model) packages are unchanged and still built by package.sh / +// package.ps1 / the release workflow. Only the extension is built for the browser — +// the relay servers themselves never run there. +// +// node build-wasm.mjs # every browser extension +// node build-wasm.mjs nextalk-relay # only the named extension(s) +// node build-wasm.mjs --version 1.4.0 # stamp manifest.version (release builds) +// node build-wasm.mjs --out /tmp/x # output directory (default: ./dist) +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { writeNtx, validateManifest } from './tools/ntx.mjs'; +import { verifyWasm } from './tools/verify-wasm.mjs'; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const args = process.argv.slice(2); +const opt = (name) => { const i = args.indexOf(name); if (i < 0) return null; const v = args[i + 1]; if (!v || v.startsWith('--')) { console.error(`${name} needs a value`); process.exit(2); } return v; }; +const version = opt('--version'), outOpt = opt('--out'); +const dist = path.resolve(outOpt || path.join(root, 'dist')); +const skipIdx = new Set(); for (const f of ['--version', '--out']) { const i = args.indexOf(f); if (i >= 0) { skipIdx.add(i); skipIdx.add(i + 1); } } +const only = args.filter((a, i) => !a.startsWith('--') && !skipIdx.has(i)); +if (version !== null && !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) { console.error(`--version must look like 1.2.3 or 1.2.3-rc.1, got ${JSON.stringify(version)}`); process.exit(2); } + +const readJSON = (p) => JSON.parse(fs.readFileSync(p, 'utf8')); +const exts = fs.readdirSync(root, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !['dist', 'node_modules', 'tools', 'test'].includes(d.name) && !d.name.startsWith('.')) + .map((d) => d.name).filter((n) => fs.existsSync(path.join(root, n, 'wasm', 'manifest.json'))) + .filter((n) => only.length === 0 || only.includes(n)).sort(); +if (!exts.length) { console.error('no browser extensions found' + (only.length ? ` matching ${only.join(', ')}` : '')); process.exit(1); } + +fs.rmSync(dist, { recursive: true, force: true }); fs.mkdirSync(dist, { recursive: true }); +const sums = []; let failed = 0; +const fail = (m) => { failed++; console.error('FAIL ' + m); }; + +for (const name of exts) { + const wdir = path.join(root, name, 'wasm'); + console.log(`\n== ${name} (browser)`); + try { + const raw = readJSON(path.join(wdir, 'manifest.json')); + const manifest = validateManifest(version ? { ...raw, version } : raw, { wasm: true }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ntx-')); + try { + const entryPath = path.join(tmp, manifest.entry); + if (fs.existsSync(path.join(wdir, 'build.mjs'))) { + const r = spawnSync(process.execPath, [path.join(wdir, 'build.mjs'), tmp], { stdio: 'inherit', cwd: wdir }); + if (r.status !== 0) throw new Error('wasm/build.mjs failed'); + } else if (fs.existsSync(path.join(wdir, manifest.entry))) fs.copyFileSync(path.join(wdir, manifest.entry), entryPath); + if (!fs.existsSync(entryPath)) throw new Error(`wasm/build.mjs must write ${manifest.entry} into its output dir`); + const bytes = fs.readFileSync(entryPath); + const extra = fs.existsSync(path.join(wdir, 'verify.json')) ? readJSON(path.join(wdir, 'verify.json')) : {}; + const fails = await verifyWasm(bytes, { manifestCaps: manifest.capabilities, loopback: !!extra.loopback }); + if (fails.length) throw new Error('ABI v1 verification:\n - ' + fails.join('\n - ')); + const res = writeNtx(dist, manifest, bytes, { wasm: true }); + sums.push(`${res.hash} ${path.basename(res.file)}`); + console.log(`ok ${path.relative(process.cwd(), res.file) || res.file} v${manifest.version} sha256 ${res.hash.slice(0, 16)}… (${bytes.length} B wasm)`); + } finally { fs.rmSync(tmp, { recursive: true, force: true }); } + } catch (e) { fail(`${name}: ${e.message}`); } +} +fs.writeFileSync(path.join(dist, 'SHA256SUMS'), sums.sort().join('\n') + '\n'); +console.log(`\n${sums.length} package(s) in ${dist} ${failed ? `— ${failed} FAILED` : '— all ok'}`); +process.exit(failed ? 1 : 0); diff --git a/transports/filerelay/README.md b/transports/filerelay/README.md index 53d4cc0..e92e343 100644 --- a/transports/filerelay/README.md +++ b/transports/filerelay/README.md @@ -1,122 +1,150 @@ -# FileRelay transport for NexTalk (`filerelay`) +# filerelay — NexTalk transport package (`.ntx`) -NexTalk's first external runtime transport, packaged as an installable -`.ntx`. No NexTalk rebuild, ever: build this bridge, zip it with the -manifest, install. +Lets NexTalk send **messages and files** through a FileRelay server (router + +shard). It installs into NexTalk like a plugin: no NexTalk rebuild needed. +Build the bridge, zip it with the manifest, install. -## What it is +- [Use a prebuilt package](#use-a-prebuilt-package-no-build-needed) +- [Quick start: files](#quick-start-send-a-file) · [Quick start: chat](#quick-start-chat-by-peer-id) +- [Which command do I need?](#which-command-do-i-need) · [Troubleshooting](#troubleshooting) +- [Two-user message scenario](#two-user-message-scenario-alice--bob) · [Configuration](#configuration) +- [Build & package](#build--package) · [Browser (WASM) package](#browser-webassembly-package) -`filerelay-bridge` is a courier transport (`message` + `binary-transfer` -capabilities, `network` + `storage` permissions) that speaks the NexTalk -transport API v1 (stdio RPC, schemas 100–126) on one side and the FileRelay -router/shard protocol (raw NanoPack over HTTP) on the other. Message ops -(1–10: initialize, start/stop, send, attach, detach, poll, status, -capabilities, register, resolve) plus file ops (11–16: xfer-create/put/ -resume/get/complete/cancel) are all live; anything else answers op 0 + -`SchemaError`. - -Trust model — courier: - -- The bridge owns its own throwaway ed25519 identities and **never sees - user private keys**. Core passes only: opaque `[type][nanopack]` frames, - 32B recipient pubkeys, explicit recipient addresses (mailbox+shard, shared - out-of-band), per-mailbox `read_secret` bearers, shard/router URLs. -- `courier.key` (shard-side sender binding: abuse/rate-limit identity) is - created next to the binary (`0600`) on first run. The true sender lives - inside the E2E frame and is verified by NexTalk core. -- Mailbox registration uses per-user scoped keys (`scoped-.key`, - `0600`) controlling only that mailbox's registration + courier sends — - never NexTalk identity keys, never decryption. Delete the key file + - re-register to revoke. -- Transfer bindings persist in `transfers.json` (`0600`) so each CLI - invocation (fresh bridge process) can resume/get/complete. -- Never commit `*.key`, `transfers.json`, or `*.ntx` (root `.gitignore` - excludes them). +--- -## Build +## Use a prebuilt package (no build needed) -```bash -cd transports/filerelay -go vet ./bridge/... -go test ./bridge/... -count=1 -go build -o filerelay-bridge ./bridge -``` +You do **not** need Go to use this transport. Every [GitHub release](https://github.com/erfanheydarzade/NexTalk-FileRelay/releases) ships ready-made packages: -Windows: `go build -o filerelay-bridge.exe ./bridge` (and use -`manifest.windows.json`, whose `entry` is the `.exe` name). +1. Open the [latest release](https://github.com/erfanheydarzade/NexTalk-FileRelay/releases/latest) and download the `.ntx` for your OS and CPU (the release has one per platform; the file name says which). +2. *(Recommended)* Check it against the SHA-256 checksum file attached to the same release: + ```bash + sha256sum -c SHA256SUMS --ignore-missing # Linux / macOS + ``` + ```powershell + Get-FileHash -Algorithm SHA256 .\filerelay.ntx # Windows: compare with the checksum file + ``` +3. Install the file you downloaded: + ```bash + nextalk transport install .ntx --enable + ``` -Helpers built into the bridge (no server needed): +The release also contains `filerelay-wasm.ntx`. That is the browser build, an in-tab loopback demo that never contacts a server. For real use, pick the native package for your OS. + +Build from source only if you want to change the bridge (see [Build & package](#build--package)). + +--- + +## Install and configure ```bash -filerelay-bridge wrap --type 3 -f payload.bin > frame.bin # prepend layer-1 type byte -filerelay-bridge --ntx-serve # stdio RPC; stderr = logs +nextalk transport install filerelay.ntx --enable ``` -## Package (`.ntx` = zip, manifest at root) +Tell NexTalk where your FileRelay router is. + +**Bash / POSIX shells:** ```bash -./package.sh # -> filerelay.ntx (manifest.json + filerelay-bridge) +nextalk transport config filerelay '{"router_url":"http://127.0.0.1:8080"}' ``` +**Windows PowerShell** (the Windows `nextalk.exe` needs the inner quotes +escaped with backslashes): + ```powershell -.\package.ps1 # -> filerelay.ntx (manifest.windows.json renamed to - # manifest.json + filerelay-bridge.exe) +.\nextalk transport config filerelay '{\"router_url\":\"http://127.0.0.1:8080\"}' ``` -Both scripts run `go vet` + `go test` first and refuse to package on -failure. Never commit the built `.ntx`. +The backslashes are only command-line escaping. The transport receives normal +JSON: `{"router_url":"http://127.0.0.1:8080"}`. -## Install & use (in NexTalk, no rebuild) +`router_url` can be the server root or the `/fr/v1/` API root, with or without +a trailing slash. The bridge normalizes all of these. -Install the transport: +--- + +## Quick start: send a file + +File transfers use a **file-transfer mailbox**, minted by plain `register`. +You share its address (mailbox + shard) or a ticket with the other side, so no +one needs to look you up by peer ID. ```bash -nextalk transport install filerelay.ntx --enable +# 1. Mint a file-transfer mailbox (auto-attached, so it can be polled right away) +nextalk transport register filerelay --id --router http://127.0.0.1:8080 +# -> mailbox, read_secret, shard (share mailbox + shard; keep read_secret private) + +# 2. Sender: upload an end-to-end-encrypted file +nextalk transport xfer-send filerelay -i --to --mailbox --shard -f photo.bin +# -> prints a download ticket (base64). Send it inside any E2E message, +# or share it out-of-band. The recipient needs only the ticket. + +# 3. Recipient: download by ticket +nextalk transport xfer-recv filerelay -i --from --ticket -o photo.bin ``` -Configure the FileRelay router: +Other transfer commands: `xfer-resume` (continue an interrupted transfer), +`xfer-cancel`, `xfer-inspect --ticket ` (look at a ticket without +downloading). Files are encrypted whole, then uploaded in opaque chunks +(`--chunk-size`, default 32 KiB). Transfer state is saved in `transfers.json`, +so a fresh process can resume. -### Bash / POSIX shells +--- + +## Quick start: chat by peer ID + +To use `peer connect ` and `message send`, **each person registers +their real identity with the Router, on the same Router.** This is different +from the file-transfer mailbox above. ```bash -nextalk transport config filerelay '{"router_url":"http://127.0.0.1:8080"}' +nextalk transport register-identity filerelay -i --router http://127.0.0.1:8080 +nextalk transport resolve filerelay --to --router http://127.0.0.1:8080 # verify ``` -### Windows PowerShell +Then in the shell: -When invoking the Windows `nextalk.exe` from PowerShell, escape the embedded -JSON quotes with backslashes: - -```powershell -.\nextalk transport config filerelay '{\"router_url\":\"http://127.0.0.1:8080\"}' +``` +identity load +use relay filerelay +peer connect # the peer must have run register-identity too +transport poll # both sides poll to finish the handshake +message send hello ``` -The same PowerShell form applies to any JSON object: +> `register-identity` needs the updated `filerelay.ntx` (the older one answers +> `filerelay transport does not support identity registration`). Rebuild, +> package and reinstall the bridge. -```powershell -.\nextalk transport config filerelay '{\"x\":\"y\"}' -``` +--- -The backslashes are command-line escaping only. The transport receives the -normal JSON object, for example: +## Which command do I need? -```json -{"router_url":"http://127.0.0.1:8080"} -``` +| I want to… | Use | Why | +|---|---|---| +| Send or receive files without tying the mailbox to my identity | `transport register` (alias `xfer-register`) | Registers a throwaway key owned by the bridge. Nobody can compute the address from your peer ID, so you share mailbox + shard (or a ticket) yourself. | +| Chat with someone using only their peer ID | `transport register-identity` | Registers your real identity key. Anyone can compute your mailbox from your peer ID, so `peer connect` finds you automatically. | +| Check whether a peer is reachable | `transport resolve --to --router ` | Asks the Router where that identity's mailbox is. | +| Drop a stale mailbox from local state | `transport detach filerelay --mailbox <32-hex>` | See [Troubleshooting](#troubleshooting). | -Then register the user: +**Most common mistake:** running `transport register` and expecting +`peer connect` to work. They use different mailboxes. Plain `register` even +prints a reminder: *"This is a file-transfer mailbox. It does NOT let peers +reach you by peer ID."* -```bash -nextalk transport register filerelay --user alice --router http://127.0.0.1:8080 -# -> {"mailbox_id":"...","read_secret":"...","shard_url":"..."} (share mailbox+shard with senders) -nextalk transport poll filerelay -i # like worker listen -nextalk transport send-frame filerelay --to -f frame.bin -``` +Registrations are **per Router**: a mailbox registered on one Router does not +exist on another (for example local dev vs. deployed). + +You can use both side by side: identity registration for chat, plain register +for files. -The bridge reads `router_url` from its initialize config blob -(`transport config` passes it at start) or from the first `attach`. +--- -Two-user message scenario (Alice ↔ Bob, same machine): +## Two-user message scenario (Alice ↔ Bob) + +Same machine, offline handshake first, then messages through the relay using +file-transfer mailboxes shared out-of-band: ```bash mkdir alice bob && cd alice && nextalk offline init # -> ALICE @@ -126,10 +154,10 @@ cd ../bob && nextalk offline accept -i BOB -f ../alice/offer.bin -o answer.bin cd ../alice && nextalk offline finish -i ALICE -f ../bob/answer.bin cd ../alice -nextalk transport register filerelay --user alice --router +nextalk transport register filerelay --id ALICE --router nextalk transport attach filerelay --mailbox --secret --shard --router cd ../bob -nextalk transport register filerelay --user bob --router +nextalk transport register filerelay --id BOB --router nextalk transport attach filerelay --mailbox --secret --shard --router cd ../alice @@ -140,17 +168,146 @@ cd ../bob && nextalk transport poll filerelay -i BOB # [+] Message from (utf-8) — stored in mailbox ``` -Files (E2E-encrypted whole-file, chunked opaquely through xfer ops): +`register` already attaches the mailbox it mints, so the explicit `attach` is +only needed to refresh a secret or to attach on another machine. `send-frame +--to` accepts a peer ID or 64-hex pubkey (resolved via the Router, which +requires that peer to have run `register-identity`), or an explicit +`--mailbox --shard ` the recipient shared out-of-band. + +Full runs: `NexTalk/docs/real-scenario.md` (scenarios B and C). Command +reference: `NexTalk/docs/transport-cli.md`. + +--- + +## Troubleshooting + +| Message | What it means | Fix | +|---|---|---| +| `recipient mailbox is not registered on the relay` | The **recipient** has no identity mailbox on this Router. | They run `transport register-identity` against the **same** Router. Verify with `transport resolve --to `. | +| `replay attach ` fails on every command | NexTalk replays the mailboxes saved in `state.json` each time it starts the bridge. They are stale (Router/shard reset, or secrets changed). | `transport detach filerelay --mailbox <32-hex>` for each, then register again. If `detach` fails the same way, close NexTalk and delete the `"attach"` block in `state.json`. | +| `filerelay transport does not support identity registration` | The installed `filerelay.ntx` is older than your NexTalk core. | Rebuild, package and reinstall the bridge. | +| `no router_url` | No Router configured. | `transport config filerelay …` or pass `--router`. | +| Works against one Router, not another | Registrations are per Router. | Use the same Router URL on both sides. | + +`state.json` lives in `os.UserConfigDir()/nextalk/transports/` (Windows: +`%APPDATA%\nextalk\transports\`) unless `NEXTALK_TRANSPORTS_DIR` is set. + +--- + +## How it works + +`filerelay-bridge` is a courier transport (`message` + `binary-transfer` +capabilities, `network` + `storage` permissions). It speaks the NexTalk +transport API v1 (stdio RPC, schemas 100–126) to NexTalk and the FileRelay +router/shard protocol (raw NanoPack over HTTP) to the network. + +- Message ops 1–10: initialize, start/stop, send, attach, detach, poll, + status, capabilities, register, resolve. +- File ops 11–16: xfer-create / put / resume / get / complete / cancel. +- Anything else answers op 0 + `SchemaError`. + +**Trust model (courier):** + +- The bridge owns its own throwaway ed25519 keys and **never sees your identity + private key**. NexTalk core passes only opaque `[type][nanopack]` frames, + 32-byte recipient pubkeys, explicit recipient addresses (mailbox + shard, + shared out-of-band), per-mailbox `read_secret` bearers, and shard/router URLs. +- `courier.key` (shard-side sender binding for abuse/rate limiting) is created + next to the binary (`0600`) on first run. The true sender lives inside the + E2E frame and is verified by NexTalk core. +- Plain `register` uses per-user scoped keys (`scoped-.key`, `0600`) that + control only that mailbox's registration and courier sends. They never touch + identity keys or decryption. Delete the key file and re-register to revoke. +- `register-identity` is signed by NexTalk core, where the identity key lives. + The bridge only receives the resulting mailbox details. +- FileRelay mailbox IDs are 16 bytes natively, so unlike the Worker Relay + bridge there is no alias map. +- Never commit `*.key`, `transfers.json` or `*.ntx` (the root `.gitignore` + excludes them). + +--- + +## Configuration + +The bridge reads `router_url` from its initialize config blob (what +`transport config` passes at start) or from the first `attach`. + +--- + +## Build & package + +> Skip this section if you use a [prebuilt package](#use-a-prebuilt-package-no-build-needed). + +```bash +cd transports/filerelay +go vet ./bridge/... +go test ./bridge/... -count=1 +go build -o filerelay-bridge ./bridge +``` + +Windows: `go build -o filerelay-bridge.exe ./bridge` (and use +`manifest.windows.json`, whose `entry` is the `.exe` name). + +Helpers built into the bridge (no server needed): ```bash -nextalk transport xfer-send filerelay -i --to --mailbox --shard -f photo.bin -# -> download ticket (base64 NanoPack): send it inside any E2E message — or mint -# it with xfer-create and share out-of-band; the recipient fetches by ticket alone -nextalk transport xfer-recv filerelay -i --from --ticket -o photo.bin +filerelay-bridge wrap --type 3 -f payload.bin > frame.bin # prepend layer-1 type byte +filerelay-bridge --ntx-serve # stdio RPC; stderr = logs ``` -Full runs: `NexTalk/docs/real-scenario.md` scenarios B and C. Command -reference: `NexTalk/docs/transport-cli.md`. +`.ntx` is a zip with the manifest at its root: + +```bash +./package.sh # -> filerelay.ntx (manifest.json + filerelay-bridge) +``` + +```powershell +.\package.ps1 # -> filerelay.ntx (manifest.windows.json renamed to + # manifest.json + filerelay-bridge.exe) +``` + +Both scripts run `go vet` + `go test` first and refuse to package on failure. +Never commit the built `.ntx`. + +After rebuilding the bridge you must **reinstall** it +(`nextalk transport install filerelay.ntx --enable`): it is a separate +executable, so a new `nextalk.exe` does not update it. + +--- + +## Browser (WebAssembly) package + +NexTalk's browser build cannot spawn processes or touch a filesystem, so it only +runs `.wasm` packages. The extension therefore also ships as +**`filerelay-wasm.ntx`**, built with Node alone (no Go, TinyGo or wat2wasm) and +reproducible byte-for-byte: + +```bash +cd transports +node build-wasm.mjs # -> dist/filerelay-wasm.ntx + dist/SHA256SUMS +node build-wasm.mjs --version 1.4.0 # stamp manifest.version (the release workflow does this) +node --test test/wasm-package.test.mjs +``` + +Every package is validated with NexTalk's manifest rules and checked against +WASM transport **ABI v1** (exports, sandboxed imports, behaviour) before it is +written. CI: `.github/workflows/ci.yml` builds, tests and uploads it +on every change; `release.yml` attaches it to each release. + +**What it does, and what it does not.** ABI v1 gives a module linear memory plus +an optional `ntx.log` import: no sockets, no `fetch`, no filesystem, and no +file-transfer ops. So `filerelay-wasm` is an **in-memory loopback queue** +(`message` only, no permissions): frames sent in a tab come back out of the same +tab. It is useful for trying the transport flow and for two identities in one +tab, but it never contacts a Router or shard. **Use the native `filerelay.ntx` +package for the real relay.** The ids differ (`filerelay` vs `filerelay-wasm`), +so both can be installed side by side. A networked browser build needs a +host-mediated network import in the ABI first. + +Only the *extension* is built for the browser; the relay servers themselves are +never compiled to or run in WebAssembly. + +--- ## Files @@ -163,12 +320,8 @@ transports/filerelay/ │ ├── rpc.go # transport API v1 codec (schemas 100–114) │ ├── rpc_file.go # file/mailbox codecs (schemas 115–126) │ ├── xfer.go # register/resolve + xfer ops (scoped keys, bindings) -│ ├── *_test.go # codec + bridge↔server interop tests +│ └── *_test.go # codec + bridge↔server interop tests +├── wasm/ # browser package: manifest.json, build.mjs, verify.json ├── package.sh / package.ps1 └── README.md # this file -``` - - -## Configuration - -The `router_url` may be either the FileRelay server root or the `/fr/v1/` API root. Both forms are normalized by the bridge, including a trailing slash. +``` \ No newline at end of file diff --git a/transports/filerelay/wasm/build.mjs b/transports/filerelay/wasm/build.mjs new file mode 100644 index 0000000..d1bf418 --- /dev/null +++ b/transports/filerelay/wasm/build.mjs @@ -0,0 +1,18 @@ +// Builds this extension's browser module with NO toolchain (no TinyGo, wat2wasm or Go): +// the ABI v1 queue module is assembled by ../../tools/wasmasm.mjs. +// node build.mjs (transports/build-wasm.mjs calls it this way) +// +// What it is: a process-wide FIFO — send() appends, poll() drains, whatever the +// recipient or mailbox. It runs entirely inside the tab and never contacts a +// Router or shard: WASM transport ABI v1 gives a module linear memory plus an +// optional ntx.log import and nothing else (no sockets, no fetch). The native +// package of this extension (manifest.json next to ../) is what talks to the relay. +import fs from 'node:fs'; +import path from 'node:path'; +import { buildQueueModule } from '../../tools/wasmasm.mjs'; +import { fileURLToPath } from 'node:url'; + +const out = process.argv[2] || path.dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(fs.readFileSync(new URL('./manifest.json', import.meta.url), 'utf8')); +fs.mkdirSync(out, { recursive: true }); +fs.writeFileSync(path.join(out, manifest.entry), buildQueueModule(manifest.capabilities.join(','))); diff --git a/transports/filerelay/wasm/manifest.json b/transports/filerelay/wasm/manifest.json new file mode 100644 index 0000000..11a5083 --- /dev/null +++ b/transports/filerelay/wasm/manifest.json @@ -0,0 +1,12 @@ +{ + "id": "filerelay-wasm", + "name": "FileRelay (in-tab loopback)", + "version": "1.0.0", + "api_version": "1", + "entry": "filerelay.wasm", + "capabilities": [ + "message" + ], + "permissions": [], + "description": "Browser build of the FileRelay courier transport: an in-memory loopback queue running as WebAssembly inside the tab. Messages only; it does not contact a FileRelay router or shard and has no file transfer (WASM ABI v1 has neither network nor transfer ops); use the native filerelay package for the real relay. No keys, opaque frames only." +} diff --git a/transports/filerelay/wasm/verify.json b/transports/filerelay/wasm/verify.json new file mode 100644 index 0000000..b621b10 --- /dev/null +++ b/transports/filerelay/wasm/verify.json @@ -0,0 +1 @@ +{ "loopback": true } diff --git a/transports/test/wasm-package.test.mjs b/transports/test/wasm-package.test.mjs new file mode 100644 index 0000000..28dae86 --- /dev/null +++ b/transports/test/wasm-package.test.mjs @@ -0,0 +1,111 @@ +// Offline tests for the browser (.wasm) extension packages. Pure Node (>= 20), no Go, no network: +// node --test transports/test/wasm-package.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { validateManifest, KNOWN_CAPABILITIES } from '../tools/ntx.mjs'; + +const transports = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const build = (...a) => spawnSync(process.execPath, [path.join(transports, 'build-wasm.mjs'), ...a], { encoding: 'utf8' }); +const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'ntx-test-')); + +// Minimal zip reader (central directory), enough for .ntx packages. +function unzip(buf) { + const files = {}; let e = buf.length - 22; + while (e >= 0 && buf.readUInt32LE(e) !== 0x06054b50) e--; + assert.ok(e >= 0, 'no end-of-central-directory record'); + const n = buf.readUInt16LE(e + 10); let p = buf.readUInt32LE(e + 16); + for (let i = 0; i < n; i++) { + assert.equal(buf.readUInt32LE(p), 0x02014b50, 'bad central directory entry'); + const method = buf.readUInt16LE(p + 10), csize = buf.readUInt32LE(p + 20), nl = buf.readUInt16LE(p + 28), xl = buf.readUInt16LE(p + 30), cl = buf.readUInt16LE(p + 32), lo = buf.readUInt32LE(p + 42); + const name = buf.toString('utf8', p + 46, p + 46 + nl); + const start = lo + 30 + buf.readUInt16LE(lo + 26) + buf.readUInt16LE(lo + 28), raw = buf.subarray(start, start + csize); + files[name] = method === 8 ? zlib.inflateRawSync(raw) : Buffer.from(raw); + p += 46 + nl + xl + cl; + } + return files; +} + +const outDir = tmp(); +const r = build('--out', outDir); +const packages = fs.readdirSync(outDir).filter((f) => f.endsWith('.ntx')).sort(); + +test('build succeeds and writes at least one package + SHA256SUMS', () => { + assert.equal(r.status, 0, r.stdout + r.stderr); + assert.ok(packages.length >= 1); + const sums = fs.readFileSync(path.join(outDir, 'SHA256SUMS'), 'utf8').trim().split('\n'); + assert.equal(sums.length, packages.length); +}); + +for (const file of packages) { + const z = unzip(fs.readFileSync(path.join(outDir, file))); + const manifest = JSON.parse(z['manifest.json']?.toString() ?? 'null'); + + test(`${file}: layout is exactly manifest.json + the .wasm entry`, () => { + validateManifest(manifest, { wasm: true }); + assert.deepEqual(Object.keys(z).sort(), ['manifest.json', manifest.entry].sort()); + assert.equal(path.basename(file, '.ntx'), manifest.id); + }); + + test(`${file}: ABI-v1 policy (no network, message only, distinct from the native id)`, () => { + // WASM ABI v1 has no sockets, fetch, filesystem or transfer ops, so a browser package + // must not claim what it cannot do (the native package carries those). + assert.deepEqual(manifest.permissions, [], 'browser package cannot hold network/storage permissions'); + assert.deepEqual(manifest.capabilities, ['message'], 'ABI v1 only implements message'); + assert.match(manifest.id, /-wasm$/); + const ext = fs.readdirSync(transports, { withFileTypes: true }).filter((d) => d.isDirectory() && fs.existsSync(path.join(transports, d.name, 'wasm', 'manifest.json'))) + .find((d) => JSON.parse(fs.readFileSync(path.join(transports, d.name, 'wasm', 'manifest.json'), 'utf8')).id === manifest.id); + assert.ok(ext, 'package has a source directory'); + const nativeManifest = path.join(transports, ext.name, 'manifest.json'); + if (fs.existsSync(nativeManifest)) assert.notEqual(JSON.parse(fs.readFileSync(nativeManifest, 'utf8')).id, manifest.id); + }); + + test(`${file}: module is sandboxed and works as a loopback queue`, async () => { + const bytes = z[manifest.entry]; + assert.ok(WebAssembly.validate(bytes)); + const mod = await WebAssembly.compile(bytes); + assert.deepEqual(WebAssembly.Module.imports(mod).map((i) => `${i.module}.${i.name}`).filter((i) => i !== 'ntx.log'), [], 'only ntx.log may be imported'); + const names = WebAssembly.Module.exports(mod).map((x) => x.name); + for (const n of ['memory', 'ntx_init', 'ntx_caps', 'ntx_start', 'ntx_stop', 'ntx_status', 'ntx_send', 'ntx_attach', 'ntx_detach', 'ntx_poll']) assert.ok(names.includes(n), `missing ${n}`); + + const ex = (await WebAssembly.instantiate(mod, { ntx: { log() {} } })).exports; + const mem = () => new Uint8Array(ex.memory.buffer); + const put = (d) => { const p = ex.ntx_alloc(d.length) >>> 0; mem().set(d, p); return p; }; + const blob = (v) => { const u = BigInt.asUintN(64, v); return mem().slice(Number(u >> 32n), Number(u >> 32n) + Number(u & 0xffffffffn)); }; + assert.equal(ex.ntx_init(put(new Uint8Array(0)), 0), 0); + const caps = new TextDecoder().decode(blob(ex.ntx_caps())).split(','); + for (const c of caps) { assert.ok(KNOWN_CAPABILITIES.includes(c)); assert.ok(manifest.capabilities.includes(c), 'module claims a capability the manifest lacks'); } + const mb = new Uint8Array(16).fill(1), rcp = new Uint8Array(32).fill(7), f1 = Uint8Array.from([3, 1, 2, 3]), f2 = Uint8Array.from([1, 9]); + assert.equal(ex.ntx_send(put(f1), f1.length, put(rcp), 32), 1, 'send before start is refused'); + assert.equal(ex.ntx_start(), 0); assert.equal(ex.ntx_status(), 1); + assert.equal(ex.ntx_attach(put(mb), put(new Uint8Array(32).fill(2)), 0, 0, 0, 0), 0); + assert.equal(ex.ntx_send(put(f1), f1.length, put(rcp), 32), 0); + assert.equal(ex.ntx_send(put(f2), f2.length, put(rcp), 32), 0); + assert.deepEqual([...blob(ex.ntx_poll(32, put(mb), 1))], [0, 0, 0, 4, 3, 1, 2, 3, 0, 0, 0, 2, 1, 9], 'frames come back BE32-prefixed, in order'); + assert.equal(blob(ex.ntx_poll(32, put(mb), 1)).length, 0, 'poll drains'); + assert.equal(ex.ntx_detach(put(mb)), 0); assert.equal(ex.ntx_stop(), 0); assert.equal(ex.ntx_status(), 0); + }); +} + +test('builds are reproducible (same sha256 twice)', () => { + const a = tmp(), b = tmp(); + assert.equal(build('--out', a).status, 0); assert.equal(build('--out', b).status, 0); + assert.equal(fs.readFileSync(path.join(a, 'SHA256SUMS'), 'utf8'), fs.readFileSync(path.join(b, 'SHA256SUMS'), 'utf8')); +}); + +test('--version stamps the manifest and bad versions are rejected', () => { + const o = tmp(); assert.equal(build('--version', '9.8.7-rc.1', '--out', o).status, 0); + const f = fs.readdirSync(o).find((x) => x.endsWith('.ntx')); + assert.equal(JSON.parse(unzip(fs.readFileSync(path.join(o, f)))['manifest.json']).version, '9.8.7-rc.1'); + assert.notEqual(build('--version', 'v1', '--out', tmp()).status, 0); + assert.notEqual(build('--version').status, 0); +}); + +test('an unknown extension name fails instead of silently building nothing', () => { + assert.notEqual(build('--out', tmp(), 'does-not-exist').status, 0); +}); diff --git a/transports/tools/ntx.mjs b/transports/tools/ntx.mjs new file mode 100644 index 0000000..73c3384 --- /dev/null +++ b/transports/tools/ntx.mjs @@ -0,0 +1,70 @@ +// .ntx packaging + manifest validation (mirrors internal/transport/manifest.go). +// Zero dependencies. Packages are REPRODUCIBLE: fixed timestamps, sorted +// members, normalised manifest JSON — the same inputs always give the same +// sha256, which is exactly what NexTalk pins at install. +import zlib from 'node:zlib'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +export const KNOWN_CAPABILITIES = ['message', 'binary-transfer', 'presence', 'local-network', 'p2p']; +export const KNOWN_PERMISSIONS = ['network', 'storage']; +const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; + +/** Throws on anything NexTalk's ParseManifest would refuse (fail closed). */ +export function validateManifest(m, { wasm = false } = {}) { + const bad = (msg) => { throw new Error('manifest: ' + msg); }; + if (typeof m !== 'object' || m === null) bad('not an object'); + if (!ID_RE.test(m.id || '')) bad(`bad id ${JSON.stringify(m.id)}`); + if (!m.name) bad('empty name'); + if (!m.version) bad('empty version'); + if (m.api_version !== '1') bad(`unsupported api_version ${JSON.stringify(m.api_version)}`); + if (!m.entry || !/^[A-Za-z0-9._-]+$/.test(m.entry) || m.entry === '.' || m.entry === '..') bad(`bad entry ${JSON.stringify(m.entry)}`); + if (!Array.isArray(m.capabilities) || m.capabilities.length === 0) bad('no capabilities'); + for (const c of m.capabilities) if (!KNOWN_CAPABILITIES.includes(c)) bad(`unknown capability ${JSON.stringify(c)}`); + for (const p of m.permissions || []) if (!KNOWN_PERMISSIONS.includes(p)) bad(`unknown permission ${JSON.stringify(p)}`); + if (wasm && !m.entry.toLowerCase().endsWith('.wasm')) bad(`browser packages need a .wasm entry, got ${JSON.stringify(m.entry)}`); + return m; +} + +const crcTable = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })(); +const crc32 = (b) => { let c = 0xffffffff; for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }; + +/** files: [[name, Buffer|Uint8Array]]; returns a Buffer (zip, deflate, fixed 1980-01-01 timestamps). */ +export function zip(files) { + const parts = [], central = []; let off = 0; + const DOS_TIME = 0, DOS_DATE = (0 << 9) | (1 << 5) | 1; // 1980-01-01 00:00 + for (const [name, data0] of [...files].sort((a, b) => (a[0] < b[0] ? -1 : 1))) { + const data = Buffer.from(data0), n = Buffer.from(name, 'utf8'), crc = crc32(data), def = zlib.deflateRawSync(data, { level: 9 }); + const [method, body] = def.length < data.length ? [8, def] : [0, data]; + const lh = Buffer.alloc(30); + lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6); lh.writeUInt16LE(method, 8); + lh.writeUInt16LE(DOS_TIME, 10); lh.writeUInt16LE(DOS_DATE, 12); lh.writeUInt32LE(crc, 14); + lh.writeUInt32LE(body.length, 18); lh.writeUInt32LE(data.length, 22); lh.writeUInt16LE(n.length, 26); + parts.push(lh, n, body); + const ch = Buffer.alloc(46); + ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(0x031e, 4); ch.writeUInt16LE(20, 6); ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(method, 10); + ch.writeUInt16LE(DOS_TIME, 12); ch.writeUInt16LE(DOS_DATE, 14); ch.writeUInt32LE(crc, 16); + ch.writeUInt32LE(body.length, 20); ch.writeUInt32LE(data.length, 24); ch.writeUInt16LE(n.length, 28); + ch.writeUInt32LE((0o100644 << 16) >>> 0, 38); ch.writeUInt32LE(off, 42); + central.push(ch, n); off += 30 + n.length + body.length; + } + const cd = Buffer.concat(central), end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(off, 16); + return Buffer.concat([...parts, cd, end]); +} + +export const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex'); + +/** Validate + write /-.ntx; returns { file, hash, manifest }. */ +export function writeNtx(outDir, manifest, entryBytes, { wasm = false, suffix = '' } = {}) { + validateManifest(manifest, { wasm }); + if (wasm && !(entryBytes.length >= 8 && entryBytes[0] === 0 && entryBytes[1] === 0x61 && entryBytes[2] === 0x73 && entryBytes[3] === 0x6d)) throw new Error('entry is not a WebAssembly module'); + const mf = Buffer.from(JSON.stringify(manifest, null, 2) + '\n'); + const pkg = zip([['manifest.json', mf], [manifest.entry, entryBytes]]); + fs.mkdirSync(outDir, { recursive: true }); + const file = path.join(outDir, `${manifest.id}${suffix}.ntx`); + fs.writeFileSync(file, pkg); + return { file, hash: sha256(pkg), manifest }; +} diff --git a/transports/tools/verify-wasm.mjs b/transports/tools/verify-wasm.mjs new file mode 100644 index 0000000..d3bd84c --- /dev/null +++ b/transports/tools/verify-wasm.mjs @@ -0,0 +1,55 @@ +// ABI v1 conformance check for a .wasm transport — mirrors what the NexTalk +// hosts (wazero natively, ntx-host.js in the browser) require, so a module +// that passes here will load there. Pure Node, no dependencies. +// +// node tools/verify-wasm.mjs [--caps message,binary-transfer] [--loopback] +import fs from 'node:fs'; +import { KNOWN_CAPABILITIES } from './ntx.mjs'; + +const MAX_FRAME = 40 * 1024; + +export async function verifyWasm(bytes, { manifestCaps = null, loopback = false } = {}) { + const fails = [], ok = (c, m) => { if (!c) fails.push(m); return c; }; + if (!ok(WebAssembly.validate(bytes), 'module does not validate')) return fails; + const mod = new WebAssembly.Module(bytes); + // Sandbox: only the optional ntx.log import is allowed (no WASI, sockets, fs). + for (const i of WebAssembly.Module.imports(mod)) ok(i.module === 'ntx' && i.name === 'log' && i.kind === 'function', `forbidden import ${i.module}.${i.name}`); + const names = WebAssembly.Module.exports(mod).map((e) => e.name); + for (const n of ['memory', 'ntx_init', 'ntx_caps', 'ntx_start', 'ntx_stop', 'ntx_send', 'ntx_attach', 'ntx_detach', 'ntx_poll', 'ntx_status']) ok(names.includes(n), `missing export ${n}`); + if (fails.length) return fails; + + let mem; const inst = new WebAssembly.Instance(mod, { ntx: { log() {} } }); const ex = inst.exports; mem = ex.memory; + let scratch = 0; const SCR = 1 << 20; + const put = (d) => { + if (ex.ntx_alloc) { const p = ex.ntx_alloc(d.length) >>> 0; new Uint8Array(mem.buffer).set(d, p); return p; } + const at = SCR + scratch; if (at + d.length > mem.buffer.byteLength) mem.grow(16); + new Uint8Array(mem.buffer).set(d, at); scratch += (d.length + 7) & ~7; return at; + }; + const blob = (packed) => { const u = BigInt.asUintN(64, packed); const p = Number(u >> 32n), l = Number(u & 0xffffffffn); return new Uint8Array(mem.buffer).slice(p, p + l); }; + const frames = (b) => { const o = []; let i = 0; while (i < b.length) { if (i + 4 > b.length) { ok(false, 'poll blob truncated'); break; } const n = (b[i] << 24 | b[i + 1] << 16 | b[i + 2] << 8 | b[i + 3]) >>> 0; i += 4; if (!ok(n > 0 && n <= MAX_FRAME && i + n <= b.length, 'poll frame out of bounds')) break; o.push(b.slice(i, i + n)); i += n; } return o; }; + + scratch = 0; ok(ex.ntx_init(put(new Uint8Array(0)), 0) === 0, 'ntx_init must return 0'); + const caps = new TextDecoder().decode(blob(ex.ntx_caps())).split(',').map((s) => s.trim()).filter(Boolean); + ok(caps.length > 0, 'ntx_caps is empty'); + for (const c of caps) { ok(KNOWN_CAPABILITIES.includes(c), `unknown capability ${c}`); if (manifestCaps) ok(manifestCaps.includes(c), `module claims "${c}" which the manifest does not declare (the host refuses this)`); } + ok(ex.ntx_status() === 0, 'status must be 0 before start'); + const frame = Uint8Array.from([3, ...Buffer.from('conformance')]), pub = new Uint8Array(32).fill(7), mb = new Uint8Array(16).fill(1), sec = new Uint8Array(32).fill(2); + scratch = 0; const fp = put(frame), rp = put(pub); + ok(ex.ntx_send(fp, frame.length, rp, pub.length) === 1, 'send before start must return 1'); + ok(ex.ntx_start() === 0, 'ntx_start must return 0'); ok(ex.ntx_status() === 1, 'status must be 1 after start'); + scratch = 0; { const m = put(mb), s = put(sec); ok(ex.ntx_attach(m, s, 0, 0, 0, 0) === 0, 'attach must return 0'); } + scratch = 0; { const f = put(frame), r = put(pub); const rc = ex.ntx_send(f, frame.length, r, pub.length); ok(rc === 0 || rc === 3, `send returned ${rc} (want 0 or 3)`); } + scratch = 0; { const m = put(mb); const got = frames(blob(ex.ntx_poll(10, m, 1))); + if (loopback) { ok(got.length === 1 && Buffer.from(got[0]).equals(Buffer.from(frame)), `loopback poll returned ${got.length} frame(s), want the one sent`); ok(frames(blob(ex.ntx_poll(10, m, 1))).length === 0, 'poll must drain'); } } + scratch = 0; ok(ex.ntx_detach(put(mb)) === 0, 'detach must return 0'); + ok(ex.ntx_stop() === 0, 'ntx_stop must return 0'); ok(ex.ntx_status() === 0, 'status must be 0 after stop'); + return fails; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const a = process.argv.slice(2), file = a.find((x) => !x.startsWith('--')); + const capsIdx = a.indexOf('--caps'); + const fails = await verifyWasm(fs.readFileSync(file), { manifestCaps: capsIdx >= 0 ? a[capsIdx + 1].split(',') : null, loopback: a.includes('--loopback') }); + if (fails.length) { console.error('FAIL ' + file + '\n - ' + fails.join('\n - ')); process.exit(1); } + console.log('ok ' + file + ' (ABI v1)'); +} diff --git a/transports/tools/wasmasm.mjs b/transports/tools/wasmasm.mjs new file mode 100644 index 0000000..37819c1 --- /dev/null +++ b/transports/tools/wasmasm.mjs @@ -0,0 +1,92 @@ +// Zero-toolchain WASM assembler + an ABI v1 in-memory queue transport. +// (Same module the NexTalk bridge tests use; no wat2wasm/tinygo needed.) +// Shared FIFO regardless of mailbox/recipient, like the Go test module: +// queue at 0x4000.., poll output at 0x8000.., ntx_alloc bump heap at 0x1000.. +const uleb = (n) => { n = BigInt(n); const o = []; do { let b = Number(n & 0x7fn); n >>= 7n; if (n) b |= 0x80; o.push(b); } while (n); return o; }; +const sleb = (n) => { n = BigInt(n); const o = []; for (;;) { const b = Number(n & 0x7fn); n >>= 7n; const sign = (b & 0x40) !== 0; if ((n === 0n && !sign) || (n === -1n && sign)) { o.push(b); return o; } o.push(b | 0x80); } }; +const str = (s) => [...uleb(s.length), ...Buffer.from(s)]; +const vec = (items) => [...uleb(items.length), ...items.flat()]; +const sec = (id, body) => [id, ...uleb(body.length), ...body]; +const I32 = 0x7f, I64 = 0x7e; +let CAPS = 'message'; // set per build (module-level so the ntx_caps body sees it) + +const op = { + i32c: (v) => [0x41, ...sleb(v)], i64c: (v) => [0x42, ...sleb(v)], + get: (i) => [0x20, ...uleb(i)], set: (i) => [0x21, ...uleb(i)], + gget: (i) => [0x23, ...uleb(i)], gset: (i) => [0x24, ...uleb(i)], + add: [0x6a], sub: [0x6b], and: [0x71], shru: [0x76], gtu: [0x4b], + store8: (off) => [0x3a, 0x00, ...uleb(off)], + ifv: [0x04, 0x40], end: [0x0b], ret: [0x0f], + ext: [0xad], shl64: [0x86], or64: [0x84], + memcopy: [0xfc, 0x0a, 0x00, 0x00], +}; +const body = (nlocals, ...ops) => { const b = [...(nlocals ? vec([[nlocals, I32]]) : [0]), ...ops.flat(), 0x0b]; return [...uleb(b.length), ...b]; }; + +// G0 running, G1 qlen, G2 bump +const Q = 0x4000, OUT = 0x8000; +const fns = [ + // [typeIdx, exportName, body] + [1, 'ntx_init', body(0, op.i32c(0))], + [2, 'ntx_caps', body(0, op.i64c((0x100n << 32n) | BigInt(Buffer.byteLength(CAPS))))], + [0, 'ntx_start', body(0, op.i32c(1), op.gset(0), op.i32c(0))], + [0, 'ntx_stop', body(0, op.i32c(0), op.gset(0), op.i32c(0))], + [0, 'ntx_status', body(0, op.gget(0))], + [5, 'ntx_alloc', body(1, // local1 = p + op.gget(2), op.set(1), + op.get(1), op.get(0), op.add, op.i32c(0x3f00), op.gtu, op.ifv, op.i32c(0x1000), op.set(1), op.end, + op.get(1), op.get(0), op.add, op.i32c(7), op.add, op.i32c(-8), op.and, op.gset(2), + op.get(1))], + [3, 'ntx_send', body(1, // params fp,fl,rp,rl ; local4 = dst + op.gget(0), [0x45], op.ifv, op.i32c(1), op.ret, op.end, + op.gget(1), op.get(1), op.add, op.i32c(4), op.add, op.i32c(0x4000), op.gtu, op.ifv, op.i32c(3), op.ret, op.end, + op.i32c(Q), op.gget(1), op.add, op.set(4), + op.get(4), op.get(1), op.i32c(24), op.shru, op.store8(0), + op.get(4), op.get(1), op.i32c(16), op.shru, op.store8(1), + op.get(4), op.get(1), op.i32c(8), op.shru, op.store8(2), + op.get(4), op.get(1), op.store8(3), + op.get(4), op.i32c(4), op.add, op.get(0), op.get(1), op.memcopy, + op.gget(1), op.get(1), op.add, op.i32c(4), op.add, op.gset(1), + op.i32c(0))], + [4, 'ntx_attach', body(0, op.i32c(0))], + [5, 'ntx_detach', body(0, op.i32c(0))], + [6, 'ntx_poll', body(0, // n = qlen + op.i32c(OUT), op.i32c(Q), op.gget(1), op.memcopy, + op.i32c(OUT), op.ext, op.i64c(32), op.shl64, op.gget(1), op.ext, op.or64, + op.i32c(0), op.gset(1))], +]; +const types = [ + [0x60, ...vec([]), ...vec([[I32]])], // 0 ()->i32 + [0x60, ...vec([[I32], [I32]]), ...vec([[I32]])], // 1 (i32,i32)->i32 + [0x60, ...vec([]), ...vec([[I64]])], // 2 ()->i64 + [0x60, ...vec([[I32], [I32], [I32], [I32]]), ...vec([[I32]])],// 3 + [0x60, ...vec(Array(6).fill([I32])), ...vec([[I32]])], // 4 + [0x60, ...vec([[I32]]), ...vec([[I32]])], // 5 (i32)->i32 + [0x60, ...vec([[I32], [I32], [I32]]), ...vec([[I64]])], // 6 (i32,i32,i32)->i64 +]; +// ntx_poll must return i64: body above leaves i64 then runs `i32.const 0; global.set 1`? No: +// the stack must hold the i64 at `end`, so reorder: compute i64, then reset qlen via a block-free trick. +fns[fns.length - 1][2] = body(0, + op.i32c(OUT), op.i32c(Q), op.gget(1), op.memcopy, + op.i32c(OUT), op.ext, op.i64c(32), op.shl64, op.gget(1), op.ext, op.or64, + op.i32c(0), op.gset(1)); // i64 stays on the stack beneath; gset consumes only its i32 + +/** capsCsv is written into the module data segment (what ntx_caps returns; max 32 bytes). */ +export function buildQueueModule(capsCsv = 'message') { + if (Buffer.byteLength(capsCsv) > 32) throw new Error('caps string too long'); + CAPS = capsCsv; fns[1][2] = body(0, op.i64c((0x100n << 32n) | BigInt(Buffer.byteLength(CAPS)))); + const exports = [ + ...fns.map(([, name], i) => [...str(name), 0x00, ...uleb(i)]), + [...str('memory'), 0x02, 0x00], + ]; + const bytes = [ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + ...sec(1, vec(types)), + ...sec(3, vec(fns.map(([t]) => uleb(t)))), + ...sec(5, vec([[0x00, 0x02]])), // memory: min 2 pages + ...sec(6, vec([[I32, 0x01, ...op.i32c(0), 0x0b], [I32, 0x01, ...op.i32c(0), 0x0b], [I32, 0x01, ...op.i32c(0x1000), 0x0b]])), + ...sec(7, vec(exports)), + ...sec(10, vec(fns.map(([, , b]) => b))), + ...sec(11, vec([[0x00, ...op.i32c(0x100), 0x0b, ...vec([[...Buffer.from(capsCsv)].map((c) => [c])].flat())]])), + ]; + return new Uint8Array(bytes); +}