1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
//! Caller identity + repository-owner resolution and the namespace-write
//! guard.
//!
//! An owner is one of two shapes: a **pod username** (an authenticated
//! agent's local account) or a **64-hex `did:nostr` pubkey** (a podless
//! nostr agent). The forge's write rule is simple and fail-closed: an
//! agent may only create/push into a namespace whose first path segment
//! equals its own owner id. Pushing to another agent's namespace is a
//! `403` (JSS forge "namespace guard"), cited by behaviour only.
use solid_pod_rs::did_nostr_types::is_valid_hex_pubkey;
/// The resolved caller identity, produced by the server's WAC/auth layer
/// or by [`crate::auth`]. Mirrors the design's `ForgeAgent`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForgeAgent {
/// A pod-backed agent. `owner = username`; author id = the WebID.
Pod {
/// The agent's WebID URI (used as the author id in the spine).
webid: String,
/// The agent's pod username — the namespace it owns.
username: String,
},
/// A podless nostr agent identified by a 64-hex x-only pubkey.
Nostr {
/// Lowercase 64-hex BIP-340 x-only pubkey.
pubkey_hex: String,
},
/// No credential resolved.
Anonymous,
}
impl ForgeAgent {
/// The namespace this agent owns (its first-path-segment identity),
/// or `None` when anonymous.
#[must_use]
pub fn owner(&self) -> Option<&str> {
match self {
ForgeAgent::Pod { username, .. } => Some(username),
ForgeAgent::Nostr { pubkey_hex } => Some(pubkey_hex),
ForgeAgent::Anonymous => None,
}
}
/// The stable author id recorded in spine entries and thread
/// pointers: a WebID for pod agents, `did:nostr:<hex>` for nostr
/// agents, `"anonymous"` otherwise.
#[must_use]
pub fn author_id(&self) -> String {
match self {
ForgeAgent::Pod { webid, .. } => webid.clone(),
ForgeAgent::Nostr { pubkey_hex } => format!("did:nostr:{pubkey_hex}"),
ForgeAgent::Anonymous => "anonymous".to_string(),
}
}
/// `true` for a podless nostr agent (bodies live in forge-hosted
/// storage rather than a pod).
#[must_use]
pub fn is_nostr(&self) -> bool {
matches!(self, ForgeAgent::Nostr { .. })
}
/// May this agent write (create/push/comment) into the namespace
/// owned by `owner_segment`? True only when the agent's own owner id
/// matches the target namespace exactly. Anonymous always fails.
#[must_use]
pub fn can_write_namespace(&self, owner_segment: &str) -> bool {
match self.owner() {
Some(o) => o == owner_segment,
None => false,
}
}
}
/// Kind of an owner segment, derived from its shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerKind {
/// A pod username (anything that is not a 64-hex pubkey).
PodUsername,
/// A 64-hex `did:nostr` pubkey namespace.
NostrHex,
}
/// Classify an owner path segment. A 64-char lowercase hex string is a
/// nostr namespace; everything else is treated as a pod username.
#[must_use]
pub fn classify_owner(segment: &str) -> OwnerKind {
if is_valid_hex_pubkey(segment) {
OwnerKind::NostrHex
} else {
OwnerKind::PodUsername
}
}
/// Validate that an owner/repo path segment is well-formed: non-empty,
/// no path separators or `..`, and only characters git and the
/// filesystem tolerate in a bare-repo directory name. Rejects leading
/// dots (dotfiles) and control characters.
#[must_use]
pub fn valid_name_segment(seg: &str) -> bool {
if seg.is_empty() || seg.len() > 100 {
return false;
}
if seg == "." || seg == ".." || seg.starts_with('.') {
return false;
}
if seg.contains("..") {
return false;
}
seg.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
#[cfg(test)]
mod tests {
use super::*;
fn hex64() -> String {
"a".repeat(64)
}
#[test]
fn pod_agent_owner_and_author() {
let a = ForgeAgent::Pod {
webid: "https://pod.example/alice/profile/card#me".into(),
username: "alice".into(),
};
assert_eq!(a.owner(), Some("alice"));
assert_eq!(a.author_id(), "https://pod.example/alice/profile/card#me");
assert!(!a.is_nostr());
}
#[test]
fn nostr_agent_owner_and_author() {
let a = ForgeAgent::Nostr {
pubkey_hex: hex64(),
};
assert_eq!(a.owner(), Some(hex64().as_str()));
assert_eq!(a.author_id(), format!("did:nostr:{}", hex64()));
assert!(a.is_nostr());
}
#[test]
fn anonymous_cannot_write() {
let a = ForgeAgent::Anonymous;
assert_eq!(a.owner(), None);
assert!(!a.can_write_namespace("alice"));
assert_eq!(a.author_id(), "anonymous");
}
#[test]
fn namespace_guard_matches_own_namespace_only() {
let alice = ForgeAgent::Pod {
webid: "w".into(),
username: "alice".into(),
};
assert!(alice.can_write_namespace("alice"));
assert!(!alice.can_write_namespace("bob"));
}
#[test]
fn classify_distinguishes_hex_from_username() {
assert_eq!(classify_owner(&hex64()), OwnerKind::NostrHex);
assert_eq!(classify_owner("alice"), OwnerKind::PodUsername);
// 63 or 65 hex chars is NOT a valid pubkey → treated as username.
assert_eq!(classify_owner(&"a".repeat(63)), OwnerKind::PodUsername);
}
#[test]
fn name_segment_validation() {
assert!(valid_name_segment("my-repo"));
assert!(valid_name_segment("repo_2"));
assert!(valid_name_segment("v0.5.0"));
assert!(!valid_name_segment(""));
assert!(!valid_name_segment(".."));
assert!(!valid_name_segment(".hidden"));
assert!(!valid_name_segment("a/b"));
assert!(!valid_name_segment("a..b"));
assert!(!valid_name_segment("space bar"));
assert!(!valid_name_segment(&"x".repeat(101)));
}
}