Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
50de9b5
feat(space-host): add dynamic multi-space authority registry
rishibalakrishnan Aug 19, 2026
59bfbaa
feat(space-host): add persistent permissioned repo storage
rishibalakrishnan Aug 19, 2026
2de3b57
feat(space-host): register spaces through lifecycle handshake
rishibalakrishnan Aug 19, 2026
a1bd963
feat(space-host): sign hosted commits with PDS account keys
rishibalakrishnan Aug 19, 2026
0bdd4fe
feat(space-host): verify existing PDS write sessions
rishibalakrishnan Aug 19, 2026
230ffdb
fix(daemon): use repo parameters for space reads
rishibalakrishnan Aug 19, 2026
5342618
feat(space-host): serve direct space writes
rishibalakrishnan Aug 19, 2026
9abde1d
feat(space-host): add bound service credential minting
rishibalakrishnan Aug 19, 2026
86fc445
feat(space-host): serve permissioned repo reads
rishibalakrishnan Aug 19, 2026
26d8b2b
feat(daemon): mint bound credentials internally
rishibalakrishnan Aug 19, 2026
7f01f2e
test(space-host): cover shared spaces wire contract
rishibalakrishnan Aug 19, 2026
7717d9b
feat(daemon): add dynamic space discovery primitives
rishibalakrishnan Aug 19, 2026
d9cc30b
feat(daemon): add discovery configuration
rishibalakrishnan Aug 19, 2026
54fd006
feat(daemon): add multi-space worker supervisor
rishibalakrishnan Aug 19, 2026
35d9f2c
feat(daemon): enable dynamic space discovery
rishibalakrishnan Aug 19, 2026
4a7a9f2
refactor(space-host): resolve authority context per request via registry
rishibalakrishnan Aug 19, 2026
5f8e404
feat(space-host): acquire authorities dynamically at registration
rishibalakrishnan Aug 19, 2026
20e4414
feat(daemon): make the discovery authority filter optional
rishibalakrishnan Aug 19, 2026
d142998
fix(space-host): accept ES256K DPoP proofs
rishibalakrishnan Aug 19, 2026
b4b8ec1
fix(daemon): resolve commit keys against the configured plc
rishibalakrishnan Aug 19, 2026
7ed7f7b
feat(daemon): journal synced batches for per-projector delivery
rishibalakrishnan Aug 19, 2026
76a1d53
feat(daemon): project synced posts to the feed service ingress
rishibalakrishnan Aug 19, 2026
ac78e9a
feat(daemon): project synced posts, likes, and removals to the appview
rishibalakrishnan Aug 19, 2026
beeda09
feat(daemon): configure and wire projection destinations per space
rishibalakrishnan Aug 19, 2026
1bf8a7c
feat(daemon): acknowledge observed sync so a space accepts projections
rishibalakrishnan Aug 19, 2026
b8dc4f5
fix(daemon): accept oplog values inlined as json when routing
rishibalakrishnan Aug 19, 2026
f1958bf
chore(daemon): silence the arity lint on the projection op builder
rishibalakrishnan Aug 19, 2026
f15043e
chore(daemon): add container build
rishibalakrishnan Aug 19, 2026
6da61ce
chore(space-host): add container build
rishibalakrishnan Aug 19, 2026
ab1c8ef
test(spaces-parity): add differential parity harness, S1-S5 red
rishibalakrishnan Aug 21, 2026
f7c9e33
refactor(space-host): remove bespoke sqlite storage ahead of actor-st…
rishibalakrishnan Aug 21, 2026
8fb3443
feat(space-host): pds-identical actor-store schema
rishibalakrishnan Aug 21, 2026
aa7fce6
feat(space-host): actor-store repos passes S1-S6
rishibalakrishnan Aug 21, 2026
5e057a4
test(spaces-parity): compare stored rows with normalized revisions
rishibalakrishnan Aug 21, 2026
e6bf488
feat(space-host): compaction, blob divergence and cross-open parity
rishibalakrishnan Aug 21, 2026
2f1111a
feat(space-host): legacy store converter with parity verification
rishibalakrishnan Aug 21, 2026
3324c3e
refactor(space-host): align the in-memory backing with stored semantics
rishibalakrishnan Aug 21, 2026
32e3212
chore(space-host): satisfy rustfmt
rishibalakrishnan Aug 21, 2026
d0742ff
fix(space-host): match the oracle's xrpc error names and op hydration
rishibalakrishnan Aug 21, 2026
3ef2fdd
test(spaces-parity): two-process layer 2 acceptance gate
rishibalakrishnan Aug 21, 2026
445299f
docs(spaces-parity): layer 2 run report
rishibalakrishnan Aug 21, 2026
f2b6ed0
test(spaces-parity): daemon resume-across-swap gate
rishibalakrishnan Aug 21, 2026
88057a9
fix(space-host): include parity crate manifest in image workspace res…
rishibalakrishnan Aug 24, 2026
1d80145
fix(space-host): mint managing-app service auth from the actor-store key
rishibalakrishnan Aug 24, 2026
a9fdb9f
fix(space-host): resolve verification keys by purpose
rishibalakrishnan Aug 24, 2026
8db9aab
fix(space-host): write space stores to their own directory
rishibalakrishnan Aug 24, 2026
81a6a55
fix(space-host): accept a high-S signature on client-presented JWSs
rishibalakrishnan Aug 24, 2026
fe76d11
fix(space-host): name a client JWS failure for what it is
rishibalakrishnan Aug 24, 2026
9852e68
fix(space-host): normalise a client JWS signature on secp256k1
rishibalakrishnan Aug 25, 2026
a8fd66b
fix(daemon): retry an admission denial on its own slow budget
rishibalakrishnan Aug 25, 2026
c9c5840
test(spaces-parity): give the resume gate's converged host a store dir
rishibalakrishnan Aug 25, 2026
7446b92
fix(space-host): tolerate partial actor-store account dirs at boot
rishibalakrishnan Aug 25, 2026
46437ed
fix(rsky-daemon): publish service DID document
rishibalakrishnan Aug 26, 2026
64b0d22
fix(rsky-daemon): include spaces-parity manifest in the image build
rishibalakrishnan Aug 26, 2026
c89644b
fix(rsky-daemon): publish syncer service endpoint
rishibalakrishnan Aug 26, 2026
a6787bc
fix(space-host): allow browser space writes
rishibalakrishnan Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
402 changes: 351 additions & 51 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ members = [
"rsky-satnav",
"rsky-space",
"rsky-space-host",
"rsky-spaces-parity",
"rsky-syntax",
"rsky-video",
"rsky-wintermute",
Expand Down
3 changes: 2 additions & 1 deletion rsky-daemon/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rsky-daemon"
version = "0.4.0"
version = "0.6.3"
authors = ["Rudy Fraser <him@rudyfraser.com>"]
description = "atproto permissioned-data syncer daemon: pulls, verifies, and indexes permissioned repos from members' PDSes"
edition = "2021"
Expand Down Expand Up @@ -36,6 +36,7 @@ chrono = { version = "0.4", features = ["serde"] }
axum = "0.7"
base64 = "0.22"
sha2 = { workspace = true }
secp256k1 = { workspace = true }

[dev-dependencies]
secp256k1 = { workspace = true }
Expand Down
82 changes: 82 additions & 0 deletions rsky-daemon/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Use the official Rust image.
# https://hub.docker.com/_/rust
FROM rust AS builder

WORKDIR /usr/src/rsky

# Copy workspace and all crate manifests for dependency resolution
COPY Cargo.toml Cargo.lock rust-toolchain ./
COPY palomar-sync/Cargo.toml palomar-sync/Cargo.toml
COPY rsky-common/Cargo.toml rsky-common/Cargo.toml
COPY rsky-crypto/Cargo.toml rsky-crypto/Cargo.toml
COPY rsky-daemon/Cargo.toml rsky-daemon/Cargo.toml
COPY rsky-feedgen/Cargo.toml rsky-feedgen/Cargo.toml
COPY rsky-firehose/Cargo.toml rsky-firehose/Cargo.toml
COPY rsky-identity/Cargo.toml rsky-identity/Cargo.toml
COPY rsky-jetstream-subscriber/Cargo.toml rsky-jetstream-subscriber/Cargo.toml
COPY rsky-labeler/Cargo.toml rsky-labeler/Cargo.toml
COPY rsky-lexicon/Cargo.toml rsky-lexicon/Cargo.toml
COPY rsky-oauth/Cargo.toml rsky-oauth/Cargo.toml
COPY rsky-pds/Cargo.toml rsky-pds/Cargo.toml
COPY rsky-relay/Cargo.toml rsky-relay/Cargo.toml
COPY rsky-repo/Cargo.toml rsky-repo/Cargo.toml
COPY rsky-satnav/Cargo.toml rsky-satnav/Cargo.toml
COPY rsky-space/Cargo.toml rsky-space/Cargo.toml
COPY rsky-space-host/Cargo.toml rsky-space-host/Cargo.toml
COPY rsky-spaces-parity/Cargo.toml rsky-spaces-parity/Cargo.toml
COPY rsky-syntax/Cargo.toml rsky-syntax/Cargo.toml
COPY rsky-video/Cargo.toml rsky-video/Cargo.toml
COPY rsky-wintermute/Cargo.toml rsky-wintermute/Cargo.toml

# Copy real source for library crates that rsky-daemon depends on
COPY rsky-common/src rsky-common/src
COPY rsky-crypto/src rsky-crypto/src
COPY rsky-identity/src rsky-identity/src
COPY rsky-lexicon/src rsky-lexicon/src
COPY rsky-oauth/src rsky-oauth/src
COPY rsky-space/src rsky-space/src
COPY rsky-syntax/src rsky-syntax/src

# Stub out the remaining workspace members so cargo can resolve the workspace
RUN mkdir -p \
palomar-sync/src rsky-feedgen/src rsky-firehose/src \
rsky-jetstream-subscriber/src rsky-labeler/src rsky-pds/src \
rsky-relay/src rsky-repo/src rsky-satnav/src rsky-space-host/src \
rsky-video/src rsky-wintermute/src && \
for crate in palomar-sync rsky-feedgen rsky-firehose \
rsky-jetstream-subscriber rsky-labeler rsky-pds rsky-relay \
rsky-satnav rsky-space-host rsky-video rsky-wintermute; do \
echo 'fn main() {}' > $crate/src/main.rs; \
done && \
touch rsky-pds/src/lib.rs rsky-repo/src/lib.rs && \
mkdir -p rsky-spaces-parity/src && touch rsky-spaces-parity/src/lib.rs && \
mkdir -p rsky-wintermute/src/bin && \
for bin in queue_backfill fix_blob_refs plc_import label_sync car_loader \
cleanup_stale_deactivated; do \
echo 'fn main() {}' > rsky-wintermute/src/bin/$bin.rs; \
done

# Create an empty src directory to trick Cargo into thinking it's a valid Rust project
RUN mkdir -p rsky-daemon/src && echo "fn main() {}" > rsky-daemon/src/main.rs

# Install production dependencies and build a release artifact.
RUN cargo build --release --package rsky-daemon

# Now copy the real source code and build the final binary
COPY rsky-daemon/src rsky-daemon/src

RUN cargo build --release --package rsky-daemon

FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/rsky
COPY --from=builder /usr/src/rsky/target/release/rsky-daemon rsky-daemon
LABEL org.opencontainers.image.source=https://github.com/blacksky-algorithms/rsky
# State is sqlite + a persisted key: mount a volume covering DAEMON_INDEX_DB_PATH
# and DAEMON_DPOP_KEY_PATH. Required env: DAEMON_SPACE_HOST_URL,
# DAEMON_SERVICE_IDENTITY, DAEMON_SERVICE_SIGNING_KEY_HEX,
# DAEMON_SPACE_HOST_MINT_TOKEN, and DAEMON_SPACE_URI and/or DAEMON_SPACES_URL
# (+ DAEMON_SPACES_API_KEY). See rsky-daemon/src/config.rs for the full reference.
CMD ["sh", "-c", "DAEMON_NOTIFY_BIND=${DAEMON_NOTIFY_BIND:-0.0.0.0:8055} ./rsky-daemon"]
271 changes: 271 additions & 0 deletions rsky-daemon/src/appview.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
//! Projection to the appview's record ingress.

use async_trait::async_trait;
use serde_json::Value;
use std::collections::BTreeMap;

use crate::error::Result;
use crate::feeds::{ProjectRecord, ProjectRecordsRequest, ProjectionIngress, ProjectionOperation};
use crate::projection::Projector;
use crate::router::{SyncEvent, LIKE_COLLECTION, MODERATION_ACTION_COLLECTION, POST_COLLECTION};

/// Projects a space's posts, likes and moderation state to the appview.
pub struct AppviewProjector<I: ProjectionIngress> {
ingress: I,
space: String,
}

impl<I: ProjectionIngress> AppviewProjector<I> {
pub fn new(ingress: I, space: impl Into<String>) -> Self {
Self {
ingress,
space: space.into(),
}
}

#[allow(clippy::too_many_arguments)]
fn op(
&self,
author: &str,
uri: &str,
cid: Option<&str>,
revision: &str,
operation: ProjectionOperation,
collection: &str,
record: Option<Value>,
) -> ProjectRecord {
ProjectRecord {
space: self.space.clone(),
author: author.to_string(),
uri: uri.to_string(),
cid: cid.map(str::to_string),
revision: revision.to_string(),
operation,
collection: collection.to_string(),
record,
action_uri: None,
neg: None,
}
}

fn moderation_op(
&self,
author: &str,
revision: &str,
uri: &str,
record: &Value,
) -> Option<ProjectRecord> {
if record["val"].as_str() != Some("remove") {
return None;
}
// A lift is its own signed record carrying the action it reverses,
// so it names that action's URI rather than its own.
let neg = record["neg"].as_bool() == Some(true);
let (action_uri, cid) = if neg {
(record["action"]["uri"].as_str(), None)
} else {
(Some(uri), record["subject"]["cid"].as_str())
};
Some(ProjectRecord {
action_uri: Some(action_uri?.to_string()),
neg: Some(neg),
..self.op(
author,
uri,
cid,
revision,
if neg {
ProjectionOperation::Unflag
} else {
ProjectionOperation::Flag
},
MODERATION_ACTION_COLLECTION,
None,
)
})
}
}

#[async_trait]
impl<I: ProjectionIngress> Projector for AppviewProjector<I> {
fn name(&self) -> &'static str {
"appview"
}

async fn project(&self, author: &str, revision: &str, events: &[SyncEvent]) -> Result<()> {
// One URI may change more than once in a batch; only its final state
// is worth sending.
let mut final_records: BTreeMap<&str, &SyncEvent> = BTreeMap::new();
for event in events {
final_records.insert(event.uri(), event);
}
let ops: Vec<ProjectRecord> = final_records
.into_values()
.filter_map(|event| match event {
SyncEvent::PostCreated {
uri, cid, record, ..
} => Some(self.op(
author,
uri,
Some(cid),
revision,
ProjectionOperation::Create,
POST_COLLECTION,
Some(record.clone()),
)),
SyncEvent::PostDeleted { uri, .. } => Some(self.op(
author,
uri,
None,
revision,
ProjectionOperation::Delete,
POST_COLLECTION,
None,
)),
SyncEvent::LikeCreated {
uri, cid, record, ..
} => Some(self.op(
author,
uri,
Some(cid),
revision,
ProjectionOperation::Create,
LIKE_COLLECTION,
Some(record.clone()),
)),
SyncEvent::LikeDeleted { uri, .. } => Some(self.op(
author,
uri,
None,
revision,
ProjectionOperation::Delete,
LIKE_COLLECTION,
None,
)),
SyncEvent::ModerationAction {
uri,
record: Some(record),
..
} => self.moderation_op(author, revision, uri, record),
SyncEvent::ModerationAction { record: None, .. } => None,
})
.collect();
if ops.is_empty() {
return Ok(());
}
self.ingress
.project_records(&ProjectRecordsRequest { ops })
.await
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::feeds::tests::{post_created, CapturingIngress, AUTHOR, SPACE};
use serde_json::json;

fn like_created(rkey: &str) -> SyncEvent {
SyncEvent::LikeCreated {
uri: format!("{SPACE}/{AUTHOR}/{LIKE_COLLECTION}/{rkey}"),
author: AUTHOR.to_string(),
cid: "bafylike".to_string(),
rev: "3krev".to_string(),
record: json!({
"$type": LIKE_COLLECTION,
"subject": {"uri": format!("{SPACE}/{AUTHOR}/{POST_COLLECTION}/3ka"), "cid": "bafypost"},
"createdAt": "2026-08-19T00:00:00Z",
}),
}
}

#[tokio::test]
async fn posts_likes_and_removals_all_project() {
let projector = AppviewProjector::new(CapturingIngress::default(), SPACE);
let action_uri = format!("{SPACE}/did:plc:community/{MODERATION_ACTION_COLLECTION}/3m");
projector
.project(
AUTHOR,
"3krev",
&[
post_created("3ka", "hello"),
like_created("3kl"),
SyncEvent::ModerationAction {
uri: action_uri.clone(),
rev: "3krev".to_string(),
record: Some(json!({
"val": "remove",
"subject": {"uri": format!("{SPACE}/{AUTHOR}/{POST_COLLECTION}/3ka"), "cid": "bafypost"},
})),
},
],
)
.await
.unwrap();

let sent = projector.ingress.sent.lock().unwrap();
assert_eq!(sent.len(), 1);
let ops = &sent[0].ops;
assert_eq!(ops.len(), 3);
let collections: Vec<&str> = ops.iter().map(|op| op.collection.as_str()).collect();
assert!(collections.contains(&POST_COLLECTION));
assert!(collections.contains(&LIKE_COLLECTION));
assert!(collections.contains(&MODERATION_ACTION_COLLECTION));
let flagged = ops
.iter()
.find(|op| op.collection == MODERATION_ACTION_COLLECTION)
.unwrap();
assert_eq!(flagged.operation, ProjectionOperation::Flag);
assert_eq!(flagged.action_uri.as_deref(), Some(action_uri.as_str()));
let like = ops
.iter()
.find(|op| op.collection == LIKE_COLLECTION)
.unwrap();
assert!(like.record.as_ref().unwrap()["subject"]["uri"]
.as_str()
.unwrap()
.contains("/space/"));
}

#[tokio::test]
async fn only_the_final_state_of_a_uri_is_sent() {
let projector = AppviewProjector::new(CapturingIngress::default(), SPACE);
let uri = format!("{SPACE}/{AUTHOR}/{POST_COLLECTION}/3ka");
projector
.project(
AUTHOR,
"3krev",
&[
post_created("3ka", "first"),
SyncEvent::PostDeleted {
uri: uri.clone(),
author: AUTHOR.to_string(),
},
],
)
.await
.unwrap();

let sent = projector.ingress.sent.lock().unwrap();
assert_eq!(sent[0].ops.len(), 1);
assert_eq!(sent[0].ops[0].operation, ProjectionOperation::Delete);
}

#[tokio::test]
async fn a_batch_with_nothing_to_send_makes_no_request() {
let projector = AppviewProjector::new(CapturingIngress::default(), SPACE);
projector
.project(
AUTHOR,
"3krev",
&[SyncEvent::ModerationAction {
uri: format!("{SPACE}/did:plc:community/{MODERATION_ACTION_COLLECTION}/3m"),
rev: "3krev".to_string(),
record: None,
}],
)
.await
.unwrap();
assert!(projector.ingress.sent.lock().unwrap().is_empty());
}
}
Loading
Loading