Skip to content

Commit 6d20b2c

Browse files
committed
fix: address copilot feedback
Signed-off-by: Trezy <tre@trezy.com>
1 parent 01541c0 commit 6d20b2c

10 files changed

Lines changed: 110 additions & 45 deletions

File tree

migrations/sqlite/20260627000000_proposal_0016_alignment.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ CREATE TABLE happyview_space_repo_state (
3939
id TEXT PRIMARY KEY,
4040
space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE,
4141
author_did TEXT NOT NULL,
42-
lthash_state BLOB NOT NULL DEFAULT X'',
42+
lthash_state BLOB NOT NULL DEFAULT (zeroblob(2048)),
4343
rev TEXT,
4444
hash BLOB,
4545
ikm BLOB,

packages/docs/content/docs/guides/lua-scripting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ When a script handles a space-scoped request, the `space` global is set to a tab
7474
| `space` | string | The full `ats://` space URI |
7575
| `space_id` | string | Internal space identifier |
7676
| `did` | string | The space's DID |
77-
| `owner_did` | string | The space authority's DID |
77+
| `authority_did` | string | The space authority's DID |
7878
| `type_nsid` | string | Space type NSID |
7979
| `skey` | string | Space key |
8080

src/admin/verification_methods.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ pub(super) async fn create(
3232
) -> Result<(StatusCode, Json<VerificationMethod>), AppError> {
3333
auth.require(Permission::SettingsManage).await?;
3434

35+
if !body.fragment_id.starts_with('#') {
36+
return Err(AppError::BadRequest(
37+
"fragment_id must start with '#'".into(),
38+
));
39+
}
40+
let frag_body = &body.fragment_id[1..];
41+
if frag_body.is_empty()
42+
|| !frag_body
43+
.chars()
44+
.all(|c| c.is_ascii_alphanumeric() || c == '_')
45+
{
46+
return Err(AppError::BadRequest(
47+
"fragment_id must contain only alphanumeric characters and underscores after '#'"
48+
.into(),
49+
));
50+
}
51+
3552
let encryption_key = state
3653
.config
3754
.token_encryption_key

src/lua/context.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pub struct SpaceContext {
88
pub space: String,
99
pub space_id: String,
1010
pub did: String,
11-
pub owner_did: String,
11+
pub authority_did: String,
1212
pub type_nsid: String,
1313
pub skey: String,
1414
}
@@ -21,7 +21,7 @@ fn set_space_context(lua: &Lua, space: Option<&SpaceContext>) -> LuaResult<()> {
2121
table.set("space", ctx.space.as_str())?;
2222
table.set("space_id", ctx.space_id.as_str())?;
2323
table.set("did", ctx.did.as_str())?;
24-
table.set("owner_did", ctx.owner_did.as_str())?;
24+
table.set("authority_did", ctx.authority_did.as_str())?;
2525
table.set("type_nsid", ctx.type_nsid.as_str())?;
2626
table.set("skey", ctx.skey.as_str())?;
2727
globals.set("space", table)?;
@@ -274,7 +274,7 @@ mod tests {
274274
space: "ats://did:plc:owner/com.example.forum/main".into(),
275275
space_id: "space-123".into(),
276276
did: "did:plc:owner".into(),
277-
owner_did: "did:plc:owner".into(),
277+
authority_did: "did:plc:owner".into(),
278278
type_nsid: "com.example.forum".into(),
279279
skey: "main".into(),
280280
};

src/spaces/auth.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,20 @@ async fn check_user_access_with_managing_app(
111111
) -> Result<bool, AppError> {
112112
// Parse DID#fragment — the fragment identifies the service endpoint in the DID doc.
113113
// For outbound callback we derive the endpoint from the DID.
114-
let (did, _fragment) = if let Some(pos) = managing_app.find('#') {
114+
let (did, fragment) = if let Some(pos) = managing_app.find('#') {
115115
(&managing_app[..pos], Some(&managing_app[pos + 1..]))
116116
} else {
117117
(managing_app, None)
118118
};
119119

120+
if let Some(frag) = fragment
121+
&& frag != "atproto_pds"
122+
{
123+
return Err(AppError::BadRequest(format!(
124+
"unsupported service fragment '#{frag}' for managing app"
125+
)));
126+
}
127+
120128
// Resolve the managing app's PDS/service endpoint from its DID document.
121129
let endpoint = resolve_did_service_endpoint(http, did).await?;
122130

src/spaces/credential.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ pub fn peek_jwt_typ(token: &str) -> Option<String> {
2525
header["typ"].as_str().map(|s| s.to_string())
2626
}
2727

28+
/// Peek at a delegation token's payload to extract the `sub` (space URI) without verifying.
29+
pub fn peek_delegation_sub(token: &str) -> Option<String> {
30+
let parts: Vec<&str> = token.split('.').collect();
31+
if parts.len() != 3 {
32+
return None;
33+
}
34+
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?;
35+
let claims: DelegationTokenClaims = serde_json::from_slice(&payload_bytes).ok()?;
36+
Some(claims.sub)
37+
}
38+
2839
/// Peek at a space credential JWT's payload to extract the `sub` (space URI) without verifying.
2940
pub fn peek_credential_sub(token: &str) -> Option<String> {
3041
let parts: Vec<&str> = token.split('.').collect();
@@ -69,6 +80,7 @@ pub fn sign_delegation_token(
6980
pub fn verify_delegation_token(
7081
token: &str,
7182
verifying_key: &K256VerifyingKey,
83+
expected_aud: &str,
7284
) -> Result<DelegationTokenClaims, AppError> {
7385
let parts: Vec<&str> = token.split('.').collect();
7486
if parts.len() != 3 {
@@ -132,6 +144,12 @@ pub fn verify_delegation_token(
132144
return Err(AppError::Auth("delegation token has expired".into()));
133145
}
134146

147+
if claims.aud != expected_aud {
148+
return Err(AppError::Auth(
149+
"delegation token audience does not match this host".into(),
150+
));
151+
}
152+
135153
Ok(claims)
136154
}
137155

@@ -448,7 +466,7 @@ mod tests {
448466
let claims = make_delegation_claims();
449467

450468
let token = sign_delegation_token(&claims, &signing_key).unwrap();
451-
let verified = verify_delegation_token(&token, &verifying_key).unwrap();
469+
let verified = verify_delegation_token(&token, &verifying_key, &claims.aud).unwrap();
452470

453471
assert_eq!(verified.iss, claims.iss);
454472
assert_eq!(verified.sub, claims.sub);
@@ -464,8 +482,21 @@ mod tests {
464482
let claims = make_delegation_claims();
465483

466484
let token = sign_delegation_token(&claims, &signing_key).unwrap();
467-
let result = verify_delegation_token(&token, &verifying_key);
485+
let result = verify_delegation_token(&token, &verifying_key, &claims.aud);
486+
assert!(result.is_err());
487+
}
488+
489+
#[test]
490+
fn delegation_rejects_wrong_aud() {
491+
let signing_key = make_k256_signing_key();
492+
let verifying_key = K256VerifyingKey::from(&signing_key);
493+
let claims = make_delegation_claims();
494+
495+
let token = sign_delegation_token(&claims, &signing_key).unwrap();
496+
let result =
497+
verify_delegation_token(&token, &verifying_key, "did:plc:wrong#atproto_space_host");
468498
assert!(result.is_err());
499+
assert!(result.unwrap_err().to_string().contains("audience"));
469500
}
470501

471502
#[test]
@@ -486,7 +517,7 @@ mod tests {
486517
};
487518

488519
let token = sign_delegation_token(&claims, &signing_key).unwrap();
489-
let result = verify_delegation_token(&token, &verifying_key);
520+
let result = verify_delegation_token(&token, &verifying_key, &claims.aud);
490521
assert!(result.is_err());
491522
assert!(result.unwrap_err().to_string().contains("expired"));
492523
}
@@ -510,7 +541,7 @@ mod tests {
510541
URL_SAFE_NO_PAD.encode(sig.to_bytes())
511542
);
512543

513-
let result = verify_delegation_token(&token, &verifying_key);
544+
let result = verify_delegation_token(&token, &verifying_key, &claims.aud);
514545
assert!(result.is_err());
515546
assert!(result.unwrap_err().to_string().contains("typ"));
516547
}

src/spaces/integration_tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ mod tests {
150150
assert_eq!(peek_jwt_typ(&token).as_deref(), Some(DELEGATION_TOKEN_TYP));
151151

152152
// Step 2: space host verifies delegation token
153-
let verified_delegation = verify_delegation_token(&token, &vk).unwrap();
153+
let verified_delegation = verify_delegation_token(&token, &vk, &delegation.aud).unwrap();
154154
assert_eq!(verified_delegation.iss, "did:plc:member");
155155
assert_eq!(
156156
verified_delegation.sub,
@@ -203,7 +203,7 @@ mod tests {
203203
jti: make_jti(),
204204
};
205205
let token = sign_delegation_token(&delegation, &sk).unwrap();
206-
let result = verify_delegation_token(&token, &vk);
206+
let result = verify_delegation_token(&token, &vk, &delegation.aud);
207207
assert!(result.is_err());
208208
assert!(result.unwrap_err().to_string().contains("expired"));
209209
}

src/spaces/routes.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Resu
397397
return Ok(());
398398
}
399399
Err(AppError::Forbidden(
400-
"Only the space owner can perform this action".into(),
400+
"Only the space authority can perform this action".into(),
401401
))
402402
}
403403

@@ -452,6 +452,22 @@ fn content_cid(record: &serde_json::Value) -> String {
452452
format!("bafyrei{}", hex::encode(&hash[..20]))
453453
}
454454

455+
async fn resolve_client_id_url(
456+
state: &AppState,
457+
client_key: &str,
458+
) -> Result<Option<String>, AppError> {
459+
let sql = adapt_sql(
460+
"SELECT client_id_url FROM happyview_api_clients WHERE client_key = ?",
461+
state.db_backend,
462+
);
463+
let row: Option<(String,)> = sqlx::query_as(&sql)
464+
.bind(client_key)
465+
.fetch_optional(&state.db)
466+
.await
467+
.map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?;
468+
Ok(row.map(|(url,)| url))
469+
}
470+
455471
// ---------------------------------------------------------------------------
456472
// Space read handlers
457473
// ---------------------------------------------------------------------------
@@ -1337,12 +1353,27 @@ async fn get_space_credential(
13371353
k256::ecdsa::VerifyingKey::from(&signing_key)
13381354
};
13391355

1340-
let delegation_claims =
1341-
crate::spaces::credential::verify_delegation_token(&input.grant, &verifying_key)?;
1356+
let delegation_claims = {
1357+
let unverified_sub = crate::spaces::credential::peek_delegation_sub(&input.grant)
1358+
.ok_or_else(|| AppError::Auth("invalid delegation token".into()))?;
1359+
let space_did = crate::spaces::SpaceUri::parse(&unverified_sub)
1360+
.map(|u| u.did.clone())
1361+
.unwrap_or_default();
1362+
let expected_aud = format!("{space_did}#atproto_space_host");
1363+
crate::spaces::credential::verify_delegation_token(
1364+
&input.grant,
1365+
&verifying_key,
1366+
&expected_aud,
1367+
)?
1368+
};
13421369

13431370
let space = resolve_space(&state, &delegation_claims.sub).await?;
13441371

1345-
let client_id = claims.client_key().map(|k| k.to_string());
1372+
let client_id = if let Some(key) = claims.client_key() {
1373+
resolve_client_id_url(&state, key).await?
1374+
} else {
1375+
None
1376+
};
13461377
let issued = crate::spaces::auth::issue_credential(
13471378
&state.db,
13481379
state.db_backend,

src/spaces/simplespace.rs

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -160,29 +160,6 @@ fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> {
160160
.ok_or_else(|| AppError::Auth("This endpoint requires authentication".into()))
161161
}
162162

163-
async fn require_auth_or_credential(
164-
state: &AppState,
165-
claims: &XrpcClaims,
166-
) -> Result<String, AppError> {
167-
if let Some(identity) = &claims.identity {
168-
return Ok(identity.did().to_string());
169-
}
170-
171-
if let Some(token) = &claims.space_credential {
172-
let verified = crate::spaces::credential::verify_external_credential(
173-
token,
174-
&state.http,
175-
&state.config.plc_url,
176-
)
177-
.await?;
178-
return Ok(verified.sub);
179-
}
180-
181-
Err(AppError::Auth(
182-
"This endpoint requires authentication".into(),
183-
))
184-
}
185-
186163
async fn resolve_space(state: &AppState, space_uri: &str) -> Result<Space, AppError> {
187164
let uri = SpaceUri::parse(space_uri)?;
188165
db::get_space_by_address(
@@ -214,7 +191,7 @@ async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Resu
214191
return Ok(());
215192
}
216193
Err(AppError::Forbidden(
217-
"Only the space owner can perform this action".into(),
194+
"Only the space authority can perform this action".into(),
218195
))
219196
}
220197

@@ -380,8 +357,9 @@ async fn list_members(
380357
let space = resolve_space(&state, &query.space).await?;
381358

382359
if !space.config.membership_public {
383-
let did = require_auth_or_credential(&state, &xrpc_claims).await?;
384-
let member = members::is_member(&state.db, state.db_backend, &space.id, &did).await?;
360+
let claims = require_auth(&xrpc_claims)?;
361+
let member =
362+
members::is_member(&state.db, state.db_backend, &space.id, claims.did()).await?;
385363
member.ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?;
386364
}
387365

tests/e2e_feature_flags.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async fn space_routes_blocked_when_flag_disabled() {
6262
.clone()
6363
.oneshot(
6464
Request::builder()
65-
.uri("/xrpc/dev.happyview.space.list")
65+
.uri("/xrpc/com.atproto.space.listSpaces")
6666
.body(Body::empty())
6767
.unwrap(),
6868
)
@@ -99,7 +99,7 @@ async fn space_routes_allowed_after_enabling_flag() {
9999
.clone()
100100
.oneshot(
101101
Request::builder()
102-
.uri("/xrpc/dev.happyview.space.list")
102+
.uri("/xrpc/com.atproto.space.listSpaces")
103103
.body(Body::empty())
104104
.unwrap(),
105105
)
@@ -151,7 +151,7 @@ async fn space_routes_blocked_again_after_disabling_flag() {
151151
.clone()
152152
.oneshot(
153153
Request::builder()
154-
.uri("/xrpc/dev.happyview.space.list")
154+
.uri("/xrpc/com.atproto.space.listSpaces")
155155
.body(Body::empty())
156156
.unwrap(),
157157
)

0 commit comments

Comments
 (0)