Skip to main content

Data Validation in Juno: Best Practices and Security Considerations

· 12 min read

Photo by Johann Walter Bantz


Why Data Validation Matters in Decentralized Apps

Data validation is always important. However, web3 comes with its own set of challenges which makes validation an even more important part of building trustworthy apps:

  1. No Central Administrator: Unlike traditional systems, decentralized apps have no admin backdoor to fix data issues
  2. Limited Data Access: Developers often can't directly access or examine user data due to encryption and/or privacy
  3. Data Immutability: Once written to the blockchain, data can be difficult or impossible to modify
  4. Client-Side Vulnerability: Front-end validation can be bypassed by determined users (like in web2)
  5. Security Risks: Invalid or malicious data can compromise application integrity and user trust

Getting validation right from the start is not just a best practice—it's essential for the secure and reliable operation of your application.


Available Approaches

Juno offers three main approaches for data validation:

  1. Hooks (on_set_doc)
  2. Custom Endpoints
  3. Assertion Hooks (assert_set_doc) 👈 --- Recommended approach

Let's explore each approach with simple examples:


on_set_doc Hooks

on_set_doc is a Hook that is triggered after a document has been written to the database. It offers a way to execute custom logic whenever data is added or updated to a collection using the setDoc function executed on the client side.

This allows for many use-cases, even for certain types of validation, but this hook runs after the data has already been written.

// Example of validation and cleanup in on_set_doc
#[on_set_doc(collections = ["users"])]
fn on_set_doc(context: OnSetDocContext) -> Result<(), String> {
// Step 1: Get all context data we'll need upfront
let collection = context.data.collection;
let key = context.data.key;
let doc = &context.data.data.after; // Reference to the full document after update
let user_data: UserData = decode_doc_data(&doc.data)?; // Decoded custom data from the document

// Step 2: Validate the data
if user_data.username.len() < 3 {
// Step 3: If validation fails, delete the document using low-level store function
delete_doc_store(
ic_cdk::id(), // Use Satellite's Principal ID since this is a system operation
collection,
key,
DelDoc {
version: Some(doc.version), // Use the version from our doc reference
}
)?;

// Log the error instead of returning it to avoid trapping
ic_cdk::print("Username must be at least 3 characters");
}

Ok(())
}

Issues:

  • The on_set_doc hook only executes AFTER data is already written to the database, which is not ideal for validation.
  • Since it only happens after the data is already written, it can lead to unwanted effects. For example: let's say a new data needs to be added to some list. If it is invalid, we can't add it to the list, but since the hook runs after the data is written, the data will be added to the list anyway before we can reject them. This adds unwanted complexity to your code, forcing the developer to manage multiple on_set_doc hooks in the same function.
  • Overhead: invalid data is written (costly operation) then might be rejected and need to be deleted (another costly operation)
  • Not ideal for validation since it can't prevent invalid writes
  • Can't return success/error messages to the frontend

There are also other Juno hooks, but in general, they provide a way to execute custom logic whenever data is added, modified, or deleted from a Juno datastore collection.


Custom Endpoints using Serverless Functions

Custom Endpoints are Juno serverless functions that expose new API endpoints through Candid (the Internet Computer's interface description language). They provide a validation layer through custom API routes before data reaches Juno's datastore, allowing for complex multi-step operations with custom validation logic.

caution

This example is provided as-is and is intended for demonstration purposes only. It does not include comprehensive security validations.

use junobuild_satellite::{set_doc_store, SetDoc};  // SetDoc is the struct type for document creation/updates
use junobuild_utils::encode_doc_data;
use ic_cdk::caller;
use candid::{CandidType, Deserialize};

// Simple user data structure
#[derive(CandidType, Deserialize)]
struct UserData {
username: String,
}

// Custom endpoint for user creation with basic validation
#[ic_cdk_macros::update]
async fn create_user(key: String, user_data: UserData) -> Result<(), String> {
// Step 1: Validate username (only alphanumeric characters)
if !user_data.username.chars().all(|c| c.is_alphanumeric()) {
return Err("Username must contain only letters and numbers".to_string());
}

// Step 2: Create and store document
// First encode our data into a blob that Juno can store into the 'data' field
let encoded_data = encode_doc_data(&user_data)
.map_err(|e| format!("Failed to encode user data: {}", e))?;

// Create a SetDoc instance - this is the required format for setting documents in Juno
// SetDoc contains only what we want to store - Juno handles all metadata:
// - created_at/updated_at timestamps
// - owner (based on caller's Principal)
// - version management
let doc = SetDoc {
data: encoded_data, // The actual data we want to store (as encoded blob)
description: None, // Optional field for filtering/searching
version: None // None for new docs, Some(version) for updates
};

// Use set_doc_store to save the document
// This is Juno's low-level storage function that:
// 1. Takes ownership of the document (caller's Principal)
// 2. Adds timestamps (created_at, updated_at)
// 3. Handles versioning
// 4. Stores the document in the specified collection
set_doc_store(
caller(), // Who is creating this document
String::from("users"), // Which collection to store in
key, // The document's unique key
doc // The SetDoc we prepared above
).await
}

While custom endpoints offer great flexibility for building specialized workflows, they introduce important security considerations. A key issue is that the original setDoc endpoint remains accessible — meaning users can, to some extension, still bypass your custom validation logic by calling the standard Juno SDK methods directly from the frontend. As a result, even if you've added strict validation in your custom endpoints, the underlying collection can still be modified unless you take additional steps to restrict access.

The common workaround is to restrict the datastore collection to "controller" access so the public can't write to it directly, forcing users to interact only through your custom functions. However, this approach creates its own problems:

  1. All documents will now be "owned" by the controller, not individual users
  2. You lose Juno's built-in permission system for user-specific data access
  3. You'll need to build an entirely new permission system from scratch
  4. This creates a complex, error-prone "hacky workaround" instead of using Juno as designed

Key Limitations:

  • Original setDoc endpoint remains accessible to users
  • Users can bypass custom endpoint entirely by using Juno's default endpoints directly (setDoc, setDocs, etc)
  • Restricting collections to controller access breaks Juno's permission model
  • Requires building a custom permission system from scratch
  • Splits validation logic from data storage

The assert_set_doc hook runs BEFORE any data is written to the database, allowing you to validate and reject invalid submissions immediately. This is the most secure validation method in Juno as it integrates directly with the core data storage mechanism.

When a user calls setDoc through the Juno SDK, the assert_set_doc hook is automatically triggered before any data is written to the blockchain. If your validation logic returns an error, the entire operation is cancelled and any changes are rolled back, and the error is returned to the frontend. This ensures invalid data never reaches your datastore in the first place, saving computational resources and maintaining data integrity.

Unlike other approaches, assert_set_doc hooks:

  • Cannot be bypassed by end users
  • Integrate seamlessly with Juno's permission model
  • Allow users to continue using the standard Juno SDK
  • Keep validation logic directly in your data model
  • Conserve blockchain resources by validating before storage
  • Can reject invalid data with descriptive error messages that flow back to the frontend (unlike on_set_doc which runs after storage and can't return validation errors to users)
// Simple assert_set_doc example
#[assert_set_doc(collections = ["users"])]
fn assert_set_doc(context: AssertSetDocContext) -> Result<(), String> {
match context.data.collection.as_str() {
"users" => {
// Access username from the document
let data = context.data.data.proposed.data.as_object()
.ok_or("Invalid data format")?;

let username = data.get("username")
.and_then(|v| v.as_str())
.ok_or("Username is required")?;

// Validate username
if username.len() < 3 {
return Err("Username must be at least 3 characters".to_string());
}

Ok(())
},
_ => Ok(())
}
}

Key Advantages:

  • Always runs BEFORE data is written - prevents invalid data entirely
  • Zero overhead - validation happens in memory before expensive on-chain operations
  • Cannot be bypassed or circumvented
  • Prevents invalid data from ever being written
  • Conserves resources by validating before storage
  • Integrates directly with Juno's permission model
  • Keeps validation (assert_set_doc) separate from business logic triggers (on_set_doc)
  • Makes use of Juno's built-in permissions system
  • Allows users to use setDoc as intended in Juno
  • Can return custom error messages to the frontend

Hook Execution Flow

Here's the sequence of events during a document write operation:

  1. User calls setDoc
  2. assert_set_doc hook runs (pre-validation)
    • If validation passes → continue
    • If validation fails → operation cancelled entirely
  3. Data is written to Datastore
  4. on_set_doc hook runs (post-processing)
  5. Operation completes

When and How to Use Each Approach

Use assert_set_doc For

  • Essential data validation
  • Structure and format verification
  • Required field checking
  • Value range constraints
  • Uniqueness validation
  • Relationship verification

Use on_set_doc For:

  • Post-processing operations
  • Notifications and logging
  • Derived data calculation
  • Asynchronous side effects
  • Cascading updates
  • Analytics and metrics

Use Custom Endpoints For:

  • Complex multi-step workflows
  • Specialized flows with custom logic
  • Batch processing

Best Practices Summary

  1. Use assert_set_doc for Validation: Always validate data before storage
  2. Keep Validation Close to Data: Build validation directly into your data model
  3. Layer Your Security: Combine multiple approaches for defense in depth
  4. Set Appropriate Permissions: Configure collection access rights correctly
  5. Use Version Control: Prevent race conditions with proper versioning
  6. Implement Error Handling: Provide clear feedback for validation failures
  7. Maintain Audit Trails: Log validation events for security analysis

Production Use-Case Examples

Below are more detailed, production-ready examples for each validation approach:

assert_set_doc Example

use junobuild_satellite::{
set_doc, list_docs, decode_doc_data, encode_doc_data,
Document, ListParams, ListMatcher
};
use ic_cdk::api::time;
use std::collections::HashMap;

#[assert_set_doc(collections = ["users", "votes", "tags"])]
fn assert_set_doc(context: AssertSetDocContext) -> Result<(), String> {
match context.data.collection.as_str() {
"users" => validate_user_document(&context),
"votes" => validate_vote_document(&context),
"tags" => validate_tag_document(&context),
_ => Err(format!("Unknown collection: {}", context.data.collection))
}
}

fn validate_user_document(context: &AssertSetDocContext) -> Result<(), String> {
// Decode and validate the user data structure
let user_data: UserData = decode_doc_data(&context.data.data.proposed.data)
.map_err(|e| format!("Invalid user data format: {}", e))?;

// Validate username format (3-20 chars, alphanumeric + limited symbols)
if !is_valid_username(&user_data.username) {
return Err("Username must be 3-20 characters and contain only letters, numbers, and underscores".to_string());
}

// Check username uniqueness by searching existing documents
let search_pattern = format!("username={};", user_data.username.to_lowercase());
let existing_users = list_docs(
String::from("users"),
ListParams {
matcher: Some(ListMatcher {
description: Some(search_pattern),
..Default::default()
}),
..Default::default()
},
);

// If this is an update operation, exclude the current document
let is_update = context.data.data.before.is_some();
for (doc_key, _) in existing_users.items {
if is_update && doc_key == context.data.key {
continue;
}

return Err(format!("Username '{}' is already taken", user_data.username));
}

Ok(())
}

fn validate_vote_document(context: &AssertSetDocContext) -> Result<(), String> {
// Decode vote data
let vote_data: VoteData = decode_doc_data(&context.data.data.proposed.data)
.map_err(|e| format!("Invalid vote data format: {}", e))?;

// Validate vote value constraints
if vote_data.value < -1.0 || vote_data.value > 1.0 {
return Err(format!("Vote value must be -1, 0, or 1 (got: {})", vote_data.value));
}

// Validate vote weight constraints
if vote_data.weight < 0.0 || vote_data.weight > 1.0 {
return Err(format!("Vote weight must be between 0.0 and 1.0 (got: {})", vote_data.weight));
}

// Validate tag exists
let tag_params = ListParams {
matcher: Some(ListMatcher {
key: Some(vote_data.tag_key.clone()),
..Default::default()
}),
..Default::default()
};

let existing_tags = list_docs(String::from("tags"), tag_params);
if existing_tags.items.is_empty() {
return Err(format!("Tag not found: {}", vote_data.tag_key));
}

// Prevent self-voting
if vote_data.author_key == vote_data.target_key {
return Err("Users cannot vote on themselves".to_string());
}

Ok(())
}

fn validate_tag_document(context: &AssertSetDocContext) -> Result<(), String> {
// Decode tag data
let tag_data: TagData = decode_doc_data(&context.data.data.proposed.data)
.map_err(|e| format!("Invalid tag data format: {}", e))?;

// Validate tag name format and uniqueness
if !is_valid_tag_name(&tag_data.name) {
return Err("Tag name must be 3-50 characters and contain only letters, numbers, and underscores".to_string());
}

// Check tag name uniqueness
let search_pattern = format!("name={};", tag_data.name.to_lowercase());
let existing_tags = list_docs(
String::from("tags"),
ListParams {
matcher: Some(ListMatcher {
description: Some(search_pattern),
..Default::default()
}),
..Default::default()
},
);

let is_update = context.data.data.before.is_some();
for (doc_key, _) in existing_tags.items {
if is_update && doc_key == context.data.key {
continue;
}
return Err(format!("Tag name '{}' is already taken", tag_data.name));
}

// Validate description length
if tag_data.description.len() > 1024 {
return Err(format!(
"Tag description cannot exceed 1024 characters (current length: {})",
tag_data.description.len()
));
}

// Validate time periods
validate_time_periods(&tag_data.time_periods)?;

// Validate vote reward
if tag_data.vote_reward < 0.0 || tag_data.vote_reward > 1.0 {
return Err(format!(
"Vote reward must be between 0.0 and 1.0 (got: {})",
tag_data.vote_reward
));
}

Ok(())
}

fn validate_time_periods(periods: &[TimePeriod]) -> Result<(), String> {
if periods.is_empty() {
return Err("Tag must have at least 1 time period".to_string());
}
if periods.len() > 10 {
return Err(format!(
"Tag cannot have more than 10 time periods (got: {})",
periods.len()
));
}

// Last period must be "infinity" (999 months)
let last_period = periods.last().unwrap();
if last_period.months != 999 {
return Err(format!(
"Last period must be 999 months (got: {})",
last_period.months
));
}

// Validate each period's configuration
for (i, period) in periods.iter().enumerate() {
// Validate multiplier range (0.05 to 10.0)
if period.multiplier < 0.05 || period.multiplier > 10.0 {
return Err(format!(
"Multiplier for period {} must be between 0.05 and 10.0 (got: {})",
i + 1, period.multiplier
));
}

// Validate multiplier step increments (0.05)
let multiplier_int = (period.multiplier * 100.0).round();
let remainder = multiplier_int % 5.0;
if remainder > 0.000001 {
return Err(format!(
"Multiplier for period {} must use 0.05 step increments (got: {})",
i + 1, period.multiplier
));
}

// Validate month duration
if period.months == 0 {
return Err(format!(
"Months for period {} must be greater than 0 (got: {})",
i + 1, period.months
));
}
}

Ok(())
}

Remember: Security is about preventing unauthorized or invalid operations, not just making them difficult. assert_set_doc hooks provide the only guaranteed way to validate all data operations in Juno's Datastore.


References


✍️ This blog post was contributed by Fairtale, creators of Solutio.

Solutio is a new kind of platform where users crowdfund the software they need, and developers earn by building it. Instead of waiting for maintainers or hiring devs alone, communities can come together to fund bug fixes, new features, or even entire tools — paying only when the result meets their expectations.

Solutio – Request software you need and share the costs with others

Create Juno Just Got a Big Upgrade

· One min read

🚀 Create Juno just got a big upgrade!

✨ Local dev is now the default for apps (!)
🛠 Scaffold serverless functions
🛰 Sputnik preview (WIP)
📦 Updated all template dependencies
🎓 Onboarding revamped

Give it a try 😎

With NPM:

npm create juno@latest

With Yarn:

yarn create juno

With PNPM:

pnpm create juno

Internet Identity Domain

· One min read

Morning! Great news for the Juno community, which has always used identity.internetcomputer.org as the default domain for authentication.

Internet Identity now supports passkeys on both of its domains!

This means it should no longer matters whether devs or users sign in via identity.internetcomputer.org or identity.ic0.app — the registered identity should work seamlessly across both. There are a few limitations, which is why II may prompt you to register your current device.

As a result, I’ve just launched a new, clean sign-in page with a single call to action! 🚀

To address potential sign-in issues, the page still offers domain-specific methods as a fallback. Plus, I added a brand-new footer accessible on scroll—kind of really happy with that design. 😃

👉 https://console.juno.build/

Cool, cool, cool!


References:

Cleaned login page

UI/UX Improvements

· One min read

Don’t deploy on Fridays?

I just released a new version focused entirely on improving the wallet and enhancing the user experience in the Console.

⚡ Snappier UI/UX
🔒 Improved data certification (query+update calls)
🪄 Slick new wizards for top-ups, cycle transfers, and sending ICP

Happy weekend, everyone! ☀️🚀

👉 Release v0.0.42 · junobuild/juno

Wallet USD Balance Display

· One min read

Joining the wallet 💵 display party – balances and amounts are now displayed in USD on the Juno Console too! 🎉

Kudos to KongSwap for sharing their exchange rate endpoint! 🙌

Wallet balance in USD

While I was at it, I fixed a few navigation leftovers from the last version, reviewed the UX of all transaction modals, integrated the exchange feature into each of them, and... had some fun with the colors 🎨

Transaction modal with USD

Monitor your wallet and modules

· 2 min read

I’m thrilled to unveil the first big feature of the year: a brand-new way for Juno devs to monitor your wallet and modules automatically! 🚀

✅ Keeps cycles topped up when balances run low
🔔 Sends email notifications for top-ups (opt-in)
🔁 Set it up once, never again — it’s automatically applied to all future projects

Everything is controlled through the Mission Control — which means that developers remain the sole controllers!

The feature is built using the Canfund library developed by the DFINITY Foundation. 12/10 would highly recommend it for building similar features.

This feature is part of an absolutely massive release. I believe there were over 270 commits shipped. 🤓

👉 Release v0.0.40 · junobuild/juno


In this version, I also revised the navigation to integrate Analytics, Monitoring, Mission Control, and Wallet within the main panel. This update led to the introduction of new colors. 🎨

Navigation screenshot 1

Navigation screenshot 2

Navigation screenshot 3

Navigation screenshot 4

What’s New in Juno (November 2024 Edition)

· 5 min read

Hey everyone 👋

November’s been an exciting month, especially since I’ve officially started working full-time on Juno — thanks to the recently announced funding! This shift has already led to delivering some fantastic new features for developers, like automated backups (finally!!!), support for large WASM modules, the ability to buy cycles with Stripe, and a few other goodies.

These updates are all about making development smoother and more efficient, whether you’re building dapps, smart contracts, or managing your projects. Let’s dive into what’s new!


Backups

To kick things off, I’d like to highlight the introduction of backups—a feature I’ve been waiting for forever!

This addition brings a crucial layer of security for developers, letting you safeguard your modules and restore them whenever needed.

A screenshot of the steps for an upgrade

Here’s how it works: Currently, one backup per module is supported. You can manage backups manually via both the Console UI and the CLI, with options to create, restore, or delete them. Additionally, backups are automatically created during the upgrade process, taking a snapshot before transitioning to a new version. For those who prefer full control, advanced options let you skip creating a backup or avoid overwriting an existing one.

For anyone who, like me, feels a bit tense whenever it’s time to execute an upgrade, this feature is a huge relief. It’s really a great addition!


Buy Cycles with Stripe

Getting cycles has become more straightforward, particularly for newcomers and non-crypto-native users, with the ability to buy cycles directly through Stripe, thanks to our friends at cycle.express.

A screenshot of the integration with cycle.express

With this integration, developers can simply make a payment, and the cycles are added directly to their module.


Get ICP directly from the OISY Wallet

This was both a useful feature, as it makes it easy to transfer ICP from OISY to the developer's wallet on Juno, and an opportunity for me to try out the integration with various ICRC standards I implemented for the foundation.

A screenshot of the integration with OISY

I also used the opportunity to improve the UI/UX of the Receive feature by displaying wallet addresses with a QR code. This update wraps up a few related tasks, such as adding support for sending ICP to the outside world.

A screenshot of the new modal to send ICP to the outside world


Support for Large WASM Modules

Support for larger WASM modules (over 2MB) has been added. While none of Juno's stock modules—such as Satellites, Mission Control, or Orbiter (Analytics)—come close to this size when gzipped, this limit could quickly be reached by developers using serverless functions.

A screenshot of the upgrade process using the CLI

By extending this limit, developers have more flexibility to embed additional third-party libraries and expand their module capabilities.

This support has been implemented across the CLI, the Console UI, and even local development environments using Docker, ensuring a consistent experience for all workflows.


Default Web Page

Until recently, new Satellites launched lacked a default page for web hosting. This meant that developers opening their project right after creation would just see a blank page in the browser.

That’s why every new Satellite now comes with a sleek, informative default web page—delivering a great first impression right out of the box! ✨

A screenshot of the new default web page which contains links to the documentation


Pre- and post-deploy scripts

Another handy tool introduced this month is support for pre- and post-deploy scripts in the CLI. With this feature, developers can now define a list of commands to be executed at specific stages of the deployment process.

The pre-deploy scripts are perfect for automating tasks like:

  • Compiling assets.
  • Running tests or linters.
  • Preparing production-ready files.

Likewise, post-deploy scripts come in handy for follow-up tasks, such as:

  • Sending notifications or alerts to administrators.
  • Cleaning up temporary files.
  • Logging deployment information for auditing.
import { defineConfig } from "@junobuild/config";

/** @type {import('@junobuild/config').JunoConfig} */
export default defineConfig({
satellite: {
id: "ck4tp-aaaaa-aaaaa-abbbb-cai",
source: "build",
predeploy: ["npm run lint", "npm run build"],
postdeploy: ["node hello.mjs"]
}
});

Darker Dark Theme

Maybe not the most groundbreaking update, but the dark theme got even darker. 🧛‍♂️🦇 Perfect for those late-night coding sessions—or if you just enjoy the vibe!

A screenshot of the darker dark theme


Improved Documentation

Another area that saw improvement is the documentation. I aimed to make it more intuitive and useful for both newcomers and experienced developers. That’s why I revamped the guides section. Now, when you visit, you’ll be greeted with a simple question: “What are you looking to do? Build or Host?” 🎯. This approach should hopefully make onboarding smoother and more straightforward for developers.

The CLI documentation also received an upgrade. Updating it manually was a hassle, so I automated the process. Now, CLI help commands generate markdown files that are automatically embedded into the website every week. No more manual updates for me, and it’s always up to date for you! 😄

I also dedicated time to documenting all the configuration options in detail, ensuring every setting is clearly explained.

And as a finishing touch, I refreshed the landing page. 👨‍🎨

I hope these features get you as excited as they got me! I’m already looking forward to what’s next. Speak soon for more updates!

David


Stay connected with Juno by following us on X/Twitter.

Reach out on Discord or OpenChat for any questions.

⭐️⭐️⭐️ stars are also much appreciated: visit the GitHub repo and show your support!

A New Chapter Awakens

· 3 min read

A Futuristic cosmic scene with satellites orbiting a distant planet at sunrise, symbolizing renewal and progress in a minimalist space setting


TL;DR

I’ll be working 100% on Juno through 2025.

Hey everyone 👋

As you may know, I recently proposed transforming Juno into a Decentralized Autonomous Organization through an SNS swap. Unfortunately, it didn’t reach its funding goal, so Juno didn’t become a DAO.

After the failure, three options came to mind: retrying the ICO with a lower target, continuing to hack as an indie project for a while, or simply killing it.

In the days that followed, I also received a few other options, including interest from venture capitalists for potential seed funding which wasn’t an option for me.

Then, something unexpected happened:

The DFINITY foundation’s CTO, Jan Camenisch, reached out and proposed an alternative: funding the project through 2025.

I took a few days to consider the offer and ultimately accepted.

This support is a tremendous vote of confidence in Juno’s potential and importance within the ecosystem.

It’s worth emphasizing that the foundation’s support comes with no strings attached. They do not receive any stake in Juno, have no preferential treatment, and will not influence decisions. Should I ever consider another SNS DAO or any other funding route in the future, the foundation would have no special allocation or shares. This remains my project, and I am the sole decision-maker and controller.

This support also strengthens the relationship between Juno and the foundation, allowing us to stay in close contact to discuss the roadmap. It’s an arrangement that respects autonomy while fostering collaboration to advance the Internet Computer. As they say, it takes two to tango.

This funding opens up a world of possibilities and marks the first time I’ll work 100% on a project I created. I’m thrilled to continue building Juno as a resource that makes decentralized development accessible and impactful for everyone.

Obviously, while Juno remains under my sole ownership for now, I still believe that Juno should eventually become a DAO. Promoting full control for developers while retaining centralized ownership would be paradoxical. When the time is right, a DAO will ensure that Juno’s growth, security, and transparency are upheld through community-driven governance.

Thank you to everyone who believed in Juno through the SNS campaign and beyond 🙏💙. Your support has been invaluable, and this new phase wouldn’t be possible without you. Here’s to what lies ahead—a new chapter indeed.

To infinity and beyond,
David


Stay connected with Juno by following us on X/Twitter.

Reach out on Discord or OpenChat for any questions.

⭐️⭐️⭐️ stars are also much appreciated: visit the GitHub repo and show your support!